package repository

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	goio "io"
	"io/fs"
	"net/mail"
	"os"
	"os/exec"
	"path"
	"path/filepath"
	"regexp"
	"slices"
	"sort"
	"strings"
	"sync"
	"testing"
	"time"

	log "github.com/sirupsen/logrus"
	"k8s.io/apimachinery/pkg/api/resource"
	"k8s.io/apimachinery/pkg/util/intstr"

	"github.com/argoproj/argo-cd/v3/util/oci"

	cacheutil "github.com/argoproj/argo-cd/v3/util/cache"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"
	"github.com/stretchr/testify/require"
	appsv1 "k8s.io/api/apps/v1"
	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
	"k8s.io/apimachinery/pkg/runtime"
	"sigs.k8s.io/yaml"

	"github.com/argoproj/argo-cd/v3/common"
	"github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1"
	"github.com/argoproj/argo-cd/v3/reposerver/apiclient"
	"github.com/argoproj/argo-cd/v3/reposerver/cache"
	repositorymocks "github.com/argoproj/argo-cd/v3/reposerver/cache/mocks"
	"github.com/argoproj/argo-cd/v3/reposerver/metrics"
	fileutil "github.com/argoproj/argo-cd/v3/test/fixture/path"
	"github.com/argoproj/argo-cd/v3/util/argo"
	"github.com/argoproj/argo-cd/v3/util/git"
	gitmocks "github.com/argoproj/argo-cd/v3/util/git/mocks"
	"github.com/argoproj/argo-cd/v3/util/helm"
	helmmocks "github.com/argoproj/argo-cd/v3/util/helm/mocks"
	utilio "github.com/argoproj/argo-cd/v3/util/io"
	iomocks "github.com/argoproj/argo-cd/v3/util/io/mocks"
	ocimocks "github.com/argoproj/argo-cd/v3/util/oci/mocks"
	"github.com/argoproj/argo-cd/v3/util/settings"
)

const testSignature = `gpg: Signature made Wed Feb 26 23:22:34 2020 CET
gpg:                using RSA key 4AEE18F83AFDEB23
gpg: Good signature from "GitHub (web-flow commit signing) <noreply@github.com>" [ultimate]
`

type clientFunc func(*gitmocks.Client, *helmmocks.Client, *ocimocks.Client, *iomocks.TempPaths)

type repoCacheMocks struct {
	mock.Mock
	cacheutilCache *cacheutil.Cache
	cache          *cache.Cache
	mockCache      *repositorymocks.MockRepoCache
}

type newGitRepoHelmChartOptions struct {
	chartName string
	// valuesFiles is a map of the values file name to the key/value pairs to be written to the file
	valuesFiles map[string]map[string]string
}

type newGitRepoOptions struct {
	path             string
	createPath       bool
	remote           string
	addEmptyCommit   bool
	helmChartOptions newGitRepoHelmChartOptions
}

func newCacheMocks() *repoCacheMocks {
	return newCacheMocksWithOpts(1*time.Minute, 1*time.Minute, 10*time.Second)
}

func newCacheMocksWithOpts(repoCacheExpiration, revisionCacheExpiration, revisionCacheLockTimeout time.Duration) *repoCacheMocks {
	mockRepoCache := repositorymocks.NewMockRepoCache(&repositorymocks.MockCacheOptions{
		RepoCacheExpiration:     1 * time.Minute,
		RevisionCacheExpiration: 1 * time.Minute,
		ReadDelay:               0,
		WriteDelay:              0,
	})
	cacheutilCache := cacheutil.NewCache(mockRepoCache.RedisClient)
	return &repoCacheMocks{
		cacheutilCache: cacheutilCache,
		cache:          cache.NewCache(cacheutilCache, repoCacheExpiration, revisionCacheExpiration, revisionCacheLockTimeout),
		mockCache:      mockRepoCache,
	}
}

func newServiceWithMocks(t *testing.T, root string, signed bool) (*Service, *gitmocks.Client, *repoCacheMocks) {
	t.Helper()
	root, err := filepath.Abs(root)
	if err != nil {
		panic(err)
	}
	return newServiceWithOpt(t, func(gitClient *gitmocks.Client, helmClient *helmmocks.Client, ociClient *ocimocks.Client, paths *iomocks.TempPaths) {
		gitClient.EXPECT().Init().Return(nil)
		gitClient.EXPECT().IsRevisionPresent(mock.Anything).Return(false)
		gitClient.EXPECT().Fetch(mock.Anything, mock.Anything).Return(nil)
		gitClient.EXPECT().Checkout(mock.Anything, mock.Anything, mock.Anything).Return("", nil)
		gitClient.EXPECT().LsRemote(mock.Anything).Return(mock.Anything, nil)
		gitClient.EXPECT().CommitSHA().Return(mock.Anything, nil)
		gitClient.EXPECT().Root().Return(root)
		gitClient.EXPECT().IsAnnotatedTag(mock.Anything).Return(false)
		if signed {
			gitClient.EXPECT().VerifyCommitSignature(mock.Anything).Return(testSignature, nil)
		} else {
			gitClient.EXPECT().VerifyCommitSignature(mock.Anything).Return("", nil)
		}

		chart := "my-chart"
		oobChart := "out-of-bounds-chart"
		version := "1.1.0"
		helmClient.EXPECT().GetIndex(mock.AnythingOfType("bool"), mock.Anything).Return(&helm.Index{Entries: map[string]helm.Entries{
			chart:    {{Version: "1.0.0"}, {Version: version}},
			oobChart: {{Version: "1.0.0"}, {Version: version}},
		}}, nil)
		helmClient.EXPECT().GetTags(mock.Anything, mock.Anything).Return(nil, nil)
		helmClient.EXPECT().ExtractChart(chart, version, false, int64(0), false).Return("./testdata/my-chart", utilio.NopCloser, nil)
		helmClient.EXPECT().ExtractChart(oobChart, version, false, int64(0), false).Return("./testdata2/out-of-bounds-chart", utilio.NopCloser, nil)
		helmClient.EXPECT().CleanChartCache(chart, version).Return(nil)
		helmClient.EXPECT().CleanChartCache(oobChart, version).Return(nil)

		ociClient.EXPECT().GetTags(mock.Anything, mock.Anything).Return(nil, nil)
		ociClient.EXPECT().ResolveRevision(mock.Anything, mock.Anything, mock.Anything).Return("", nil)
		ociClient.EXPECT().Extract(mock.Anything, mock.Anything).Return("./testdata/my-chart", utilio.NopCloser, nil)

		paths.EXPECT().Add(mock.Anything, mock.Anything).Return()
		paths.EXPECT().GetPath(mock.Anything).Return(root, nil)
		paths.EXPECT().GetPathIfExists(mock.Anything).Return(root)
		paths.EXPECT().GetPaths().Return(map[string]string{"fake-nonce": root})
	}, root)
}

func newServiceWithOpt(t *testing.T, cf clientFunc, root string) (*Service, *gitmocks.Client, *repoCacheMocks) {
	t.Helper()
	helmClient := &helmmocks.Client{}
	gitClient := &gitmocks.Client{}
	ociClient := &ocimocks.Client{}
	paths := &iomocks.TempPaths{}
	cf(gitClient, helmClient, ociClient, paths)
	cacheMocks := newCacheMocks()
	t.Cleanup(cacheMocks.mockCache.StopRedisCallback)
	service := NewService(metrics.NewMetricsServer(), cacheMocks.cache, RepoServerInitConstants{ParallelismLimit: 1}, &git.NoopCredsStore{}, root)

	service.newGitClient = func(_ string, _ string, _ git.Creds, _ bool, _ bool, _ string, _ string, _ ...git.ClientOpts) (client git.Client, e error) {
		return gitClient, nil
	}
	service.newHelmClient = func(_ string, _ helm.Creds, _ bool, _ string, _ string, _ ...helm.ClientOpts) helm.Client {
		return helmClient
	}
	service.newOCIClient = func(_ string, _ oci.Creds, _ string, _ string, _ []string, _ ...oci.ClientOpts) (oci.Client, error) {
		return ociClient, nil
	}
	service.gitRepoInitializer = func(_ string) goio.Closer {
		return utilio.NopCloser
	}
	service.gitRepoPaths = paths
	return service, gitClient, cacheMocks
}

func newService(t *testing.T, root string) *Service {
	t.Helper()
	service, _, _ := newServiceWithMocks(t, root, false)
	return service
}

func newServiceWithSignature(t *testing.T, root string) *Service {
	t.Helper()
	service, _, _ := newServiceWithMocks(t, root, true)
	return service
}

func newServiceWithCommitSHA(t *testing.T, root, revision string) *Service {
	t.Helper()
	var revisionErr error

	commitSHARegex := regexp.MustCompile("^[0-9A-Fa-f]{40}$")
	if !commitSHARegex.MatchString(revision) {
		revisionErr = errors.New("not a commit SHA")
	}

	service, gitClient, _ := newServiceWithOpt(t, func(gitClient *gitmocks.Client, _ *helmmocks.Client, _ *ocimocks.Client, paths *iomocks.TempPaths) {
		gitClient.EXPECT().Init().Return(nil)
		gitClient.EXPECT().IsRevisionPresent(mock.Anything).Return(false)
		gitClient.EXPECT().Fetch(mock.Anything, mock.Anything).Return(nil)
		gitClient.EXPECT().Checkout(mock.Anything, mock.Anything, mock.Anything).Return("", nil)
		gitClient.EXPECT().LsRemote(revision).Return(revision, revisionErr)
		gitClient.EXPECT().CommitSHA().Return("632039659e542ed7de0c170a4fcc1c571b288fc0", nil)
		gitClient.EXPECT().Root().Return(root)
		paths.EXPECT().GetPath(mock.Anything).Return(root, nil)
		paths.EXPECT().GetPathIfExists(mock.Anything).Return(root)
	}, root)

	service.newGitClient = func(_ string, _ string, _ git.Creds, _ bool, _ bool, _ string, _ string, _ ...git.ClientOpts) (client git.Client, e error) {
		return gitClient, nil
	}

	return service
}

func TestGenerateYamlManifestInDir(t *testing.T) {
	service := newService(t, "../../manifests/base")

	src := v1alpha1.ApplicationSource{Path: "."}
	q := apiclient.ManifestRequest{
		Repo:               &v1alpha1.Repository{},
		ApplicationSource:  &src,
		ProjectName:        "something",
		ProjectSourceRepos: []string{"*"},
	}

	// update this value if we add/remove manifests
	const countOfManifests = 50

	res1, err := service.GenerateManifest(t.Context(), &q)

	require.NoError(t, err)
	assert.Len(t, res1.Manifests, countOfManifests)

	// this will test concatenated manifests to verify we split YAMLs correctly
	res2, err := GenerateManifests(t.Context(), "./testdata/concatenated", "/", "", &q, false, &git.NoopCredsStore{}, resource.MustParse("0"), nil)
	require.NoError(t, err)
	assert.Len(t, res2.Manifests, 3)
}

func Test_GenerateManifest_KustomizeWithVersionOverride(t *testing.T) {
	t.Parallel()

	service := newService(t, "../../util/kustomize/testdata/kustomize-with-version-override")

	src := v1alpha1.ApplicationSource{Path: "."}
	q := apiclient.ManifestRequest{
		Repo:               &v1alpha1.Repository{},
		ApplicationSource:  &src,
		ProjectName:        "something",
		ProjectSourceRepos: []string{"*"},
		KustomizeOptions: &v1alpha1.KustomizeOptions{
			Versions: []v1alpha1.KustomizeVersion{},
		},
	}

	_, err := service.GenerateManifest(t.Context(), &q)
	require.ErrorAs(t, err, &settings.KustomizeVersionNotRegisteredError{Version: "v1.2.3"})

	q.KustomizeOptions.Versions = []v1alpha1.KustomizeVersion{
		{
			Name: "v1.2.3",
			Path: "kustomize",
		},
	}

	res, err := service.GenerateManifest(t.Context(), &q)
	require.NoError(t, err)
	assert.NotNil(t, res)
}

func Test_GenerateManifests_NoOutOfBoundsAccess(t *testing.T) {
	t.Parallel()

	testCases := []struct {
		name                    string
		outOfBoundsFilename     string
		outOfBoundsFileContents string
		mustNotContain          string // Optional string that must not appear in error or manifest output. If empty, use outOfBoundsFileContents.
	}{
		{
			name:                    "out of bounds JSON file should not appear in error output",
			outOfBoundsFilename:     "test.json",
			outOfBoundsFileContents: `{"some": "json"}`,
		},
		{
			name:                    "malformed JSON file contents should not appear in error output",
			outOfBoundsFilename:     "test.json",
			outOfBoundsFileContents: "$",
		},
		{
			name:                "out of bounds JSON manifest should not appear in manifest output",
			outOfBoundsFilename: "test.json",
			// JSON marshalling is deterministic. So if there's a leak, exactly this should appear in the manifests.
			outOfBoundsFileContents: `{"apiVersion":"v1","kind":"Secret","metadata":{"name":"test","namespace":"default"},"type":"Opaque"}`,
		},
		{
			name:                    "out of bounds YAML manifest should not appear in manifest output",
			outOfBoundsFilename:     "test.yaml",
			outOfBoundsFileContents: "apiVersion: v1\nkind: Secret\nmetadata:\n  name: test\n  namespace: default\ntype: Opaque",
			mustNotContain:          `{"apiVersion":"v1","kind":"Secret","metadata":{"name":"test","namespace":"default"},"type":"Opaque"}`,
		},
	}

	for _, testCase := range testCases {
		testCaseCopy := testCase
		t.Run(testCaseCopy.name, func(t *testing.T) {
			t.Parallel()

			outOfBoundsDir := t.TempDir()
			outOfBoundsFile := path.Join(outOfBoundsDir, testCaseCopy.outOfBoundsFilename)
			err := os.WriteFile(outOfBoundsFile, []byte(testCaseCopy.outOfBoundsFileContents), os.FileMode(0o444))
			require.NoError(t, err)

			repoDir := t.TempDir()
			err = os.Symlink(outOfBoundsFile, path.Join(repoDir, testCaseCopy.outOfBoundsFilename))
			require.NoError(t, err)

			mustNotContain := testCaseCopy.outOfBoundsFileContents
			if testCaseCopy.mustNotContain != "" {
				mustNotContain = testCaseCopy.mustNotContain
			}

			q := apiclient.ManifestRequest{
				Repo: &v1alpha1.Repository{}, ApplicationSource: &v1alpha1.ApplicationSource{}, ProjectName: "something",
				ProjectSourceRepos: []string{"*"},
			}
			res, err := GenerateManifests(t.Context(), repoDir, "", "", &q, false, &git.NoopCredsStore{}, resource.MustParse("0"), nil)
			require.Error(t, err)
			assert.NotContains(t, err.Error(), mustNotContain)
			require.ErrorContains(t, err, "illegal filepath")
			assert.Nil(t, res)
		})
	}
}

func TestGenerateManifests_MissingSymlinkDestination(t *testing.T) {
	repoDir := t.TempDir()
	err := os.Symlink("/obviously/does/not/exist", path.Join(repoDir, "test.yaml"))
	require.NoError(t, err)

	q := apiclient.ManifestRequest{
		Repo: &v1alpha1.Repository{}, ApplicationSource: &v1alpha1.ApplicationSource{}, ProjectName: "something",
		ProjectSourceRepos: []string{"*"},
	}
	_, err = GenerateManifests(t.Context(), repoDir, "", "", &q, false, &git.NoopCredsStore{}, resource.MustParse("0"), nil)
	require.NoError(t, err)
}

func TestGenerateManifests_K8SAPIResetCache(t *testing.T) {
	service := newService(t, "../../manifests/base")

	src := v1alpha1.ApplicationSource{Path: "."}
	q := apiclient.ManifestRequest{
		KubeVersion:        "v1.16.0",
		Repo:               &v1alpha1.Repository{},
		ApplicationSource:  &src,
		ProjectName:        "something",
		ProjectSourceRepos: []string{"*"},
	}

	cachedFakeResponse := &apiclient.ManifestResponse{Manifests: []string{"Fake"}, Revision: mock.Anything}

	err := service.cache.SetManifests(mock.Anything, &src, q.RefSources, &q, "", "", "", "", &cache.CachedManifestResponse{ManifestResponse: cachedFakeResponse}, nil, "")
	require.NoError(t, err)

	res, err := service.GenerateManifest(t.Context(), &q)
	require.NoError(t, err)
	assert.Equal(t, cachedFakeResponse, res)

	q.KubeVersion = "v1.17.0"
	res, err = service.GenerateManifest(t.Context(), &q)
	require.NoError(t, err)
	assert.NotEqual(t, cachedFakeResponse, res)
	assert.Greater(t, len(res.Manifests), 1)
}

func TestGenerateManifests_EmptyCache(t *testing.T) {
	service, gitMocks, mockCache := newServiceWithMocks(t, "../../manifests/base", false)

	src := v1alpha1.ApplicationSource{Path: "."}
	q := apiclient.ManifestRequest{
		Repo:               &v1alpha1.Repository{},
		ApplicationSource:  &src,
		ProjectName:        "something",
		ProjectSourceRepos: []string{"*"},
	}

	err := service.cache.SetManifests(mock.Anything, &src, q.RefSources, &q, "", "", "", "", &cache.CachedManifestResponse{ManifestResponse: nil}, nil, "")
	require.NoError(t, err)

	res, err := service.GenerateManifest(t.Context(), &q)
	require.NoError(t, err)
	assert.NotEmpty(t, res.Manifests)
	mockCache.mockCache.AssertCacheCalledTimes(t, &repositorymocks.CacheCallCounts{
		ExternalSets:    2,
		ExternalGets:    2,
		ExternalDeletes: 1,
	})
	gitMocks.AssertCalled(t, "LsRemote", mock.Anything)
	gitMocks.AssertCalled(t, "Fetch", mock.Anything, mock.Anything)
}

// Test that when Generate manifest is called with a source that is ref only it does not try to generate manifests or hit the manifest cache
// but it does resolve and cache the revision
func TestGenerateManifest_RefOnlyShortCircuit(t *testing.T) {
	lsremoteCalled := false
	dir := t.TempDir()
	repopath := dir + "/tmprepo"
	repoRemote := "file://" + repopath
	cacheMocks := newCacheMocks()
	t.Cleanup(cacheMocks.mockCache.StopRedisCallback)
	service := NewService(metrics.NewMetricsServer(), cacheMocks.cache, RepoServerInitConstants{ParallelismLimit: 1}, &git.NoopCredsStore{}, repopath)
	service.newGitClient = func(rawRepoURL string, root string, creds git.Creds, insecure bool, enableLfs bool, proxy string, noProxy string, opts ...git.ClientOpts) (client git.Client, e error) {
		opts = append(opts, git.WithEventHandlers(git.EventHandlers{
			// Primary check, we want to make sure ls-remote is not called when the item is in cache
			OnLsRemote: func(_ string) func() {
				return func() {
					lsremoteCalled = true
				}
			},
			OnFetch: func(_ string) func() {
				return func() {
					assert.Fail(t, "Fetch should not be called from GenerateManifest when the source is ref only")
				}
			},
		}))
		gitClient, err := git.NewClientExt(rawRepoURL, root, creds, insecure, enableLfs, proxy, noProxy, opts...)
		return gitClient, err
	}
	revision := initGitRepo(t, newGitRepoOptions{
		path:           repopath,
		createPath:     true,
		remote:         repoRemote,
		addEmptyCommit: true,
	})
	src := v1alpha1.ApplicationSource{RepoURL: repoRemote, TargetRevision: "HEAD", Ref: "test-ref"}
	repo := &v1alpha1.Repository{
		Repo: repoRemote,
	}
	q := apiclient.ManifestRequest{
		Repo:               repo,
		Revision:           "HEAD",
		HasMultipleSources: true,
		ApplicationSource:  &src,
		ProjectName:        "default",
		ProjectSourceRepos: []string{"*"},
	}
	_, err := service.GenerateManifest(t.Context(), &q)
	require.NoError(t, err)
	cacheMocks.mockCache.AssertCacheCalledTimes(t, &repositorymocks.CacheCallCounts{
		ExternalSets: 2,
		ExternalGets: 2,
	})
	assert.True(t, lsremoteCalled, "ls-remote should be called when the source is ref only")
	var revisions [][2]string
	require.NoError(t, cacheMocks.cacheutilCache.GetItem("git-refs|"+repoRemote, &revisions))
	assert.ElementsMatch(t, [][2]string{{"refs/heads/main", revision}, {"HEAD", "ref: refs/heads/main"}}, revisions)
}

// Test that calling manifest generation on source helm reference helm files that when the revision is cached it does not call ls-remote
func TestGenerateManifestsHelmWithRefs_CachedNoLsRemote(t *testing.T) {
	dir := t.TempDir()
	repopath := dir + "/tmprepo"
	cacheMocks := newCacheMocks()
	t.Cleanup(func() {
		cacheMocks.mockCache.StopRedisCallback()
		// Best-effort: make all files writable so t.TempDir() can clean up.
		// Ignore errors (e.g. git object files with restrictive permissions in CI)
		// to avoid failing the test in the cleanup phase rather than the logic phase.
		// We chmod *before* descending so that directories with restrictive permissions
		// (e.g. git object dirs set to 0444) become accessible prior to listing their
		// entries. filepath.WalkDir cannot be used here because it reads directory
		// entries before calling the callback, so a dir with 0000/0444 perms would
		// already fail before we get a chance to fix it.
		var fixPerms func(string)
		fixPerms = func(path string) {
			_ = os.Chmod(path, 0o777)
			entries, err := os.ReadDir(path)
			if err != nil {
				return
			}
			for _, e := range entries {
				fixPerms(filepath.Join(path, e.Name()))
			}
		}
		fixPerms(dir)
	})
	service := NewService(metrics.NewMetricsServer(), cacheMocks.cache, RepoServerInitConstants{ParallelismLimit: 1}, &git.NoopCredsStore{}, repopath)
	var gitClient git.Client
	var err error
	service.newGitClient = func(rawRepoURL string, root string, creds git.Creds, insecure bool, enableLfs bool, proxy string, noProxy string, opts ...git.ClientOpts) (client git.Client, e error) {
		opts = append(opts, git.WithEventHandlers(git.EventHandlers{
			// Primary check, we want to make sure ls-remote is not called when the item is in cache
			OnLsRemote: func(_ string) func() {
				return func() {
					assert.Fail(t, "LsRemote should not be called when the item is in cache")
				}
			},
		}))
		gitClient, err = git.NewClientExt(rawRepoURL, root, creds, insecure, enableLfs, proxy, noProxy, opts...)
		return gitClient, err
	}
	repoRemote := "file://" + repopath
	revision := initGitRepo(t, newGitRepoOptions{
		path:       repopath,
		createPath: true,
		remote:     repoRemote,
		helmChartOptions: newGitRepoHelmChartOptions{
			chartName:   "my-chart",
			valuesFiles: map[string]map[string]string{"test.yaml": {"testval": "test"}},
		},
	})
	src := v1alpha1.ApplicationSource{RepoURL: repoRemote, Path: ".", TargetRevision: "HEAD", Helm: &v1alpha1.ApplicationSourceHelm{
		ValueFiles: []string{"$ref/test.yaml"},
	}}
	repo := &v1alpha1.Repository{
		Repo: repoRemote,
	}
	q := apiclient.ManifestRequest{
		Repo:               repo,
		Revision:           "HEAD",
		HasMultipleSources: true,
		ApplicationSource:  &src,
		ProjectName:        "default",
		ProjectSourceRepos: []string{"*"},
		RefSources:         map[string]*v1alpha1.RefTarget{"$ref": {TargetRevision: "HEAD", Repo: *repo}},
	}
	err = cacheMocks.cacheutilCache.SetItem("git-refs|"+repoRemote, [][2]string{{"HEAD", revision}}, nil)
	require.NoError(t, err)
	_, err = service.GenerateManifest(t.Context(), &q)
	require.NoError(t, err)
	cacheMocks.mockCache.AssertCacheCalledTimes(t, &repositorymocks.CacheCallCounts{
		ExternalSets: 2,
		ExternalGets: 4,
	})
}

// ensure we can use a semver constraint range (>= 1.0.0) and get back the correct chart (1.0.0)
func TestHelmManifestFromChartRepo(t *testing.T) {
	root := t.TempDir()
	service, gitMocks, mockCache := newServiceWithMocks(t, root, false)
	source := &v1alpha1.ApplicationSource{Chart: "my-chart", TargetRevision: ">= 1.0.0"}
	request := &apiclient.ManifestRequest{
		Repo: &v1alpha1.Repository{}, ApplicationSource: source, NoCache: true, ProjectName: "something",
		ProjectSourceRepos: []string{"*"},
	}
	response, err := service.GenerateManifest(t.Context(), request)
	require.NoError(t, err)
	assert.NotNil(t, response)
	assert.Equal(t, &apiclient.ManifestResponse{
		Manifests:  []string{"{\"apiVersion\":\"v1\",\"kind\":\"ConfigMap\",\"metadata\":{\"name\":\"my-map\"}}"},
		Namespace:  "",
		Server:     "",
		Revision:   "1.1.0",
		SourceType: "Helm",
		Commands:   []string{`helm template . --name-template "" --include-crds`},
	}, response)
	mockCache.mockCache.AssertCacheCalledTimes(t, &repositorymocks.CacheCallCounts{
		ExternalSets: 1,
		ExternalGets: 0,
	})
	gitMocks.AssertNotCalled(t, "LsRemote", mock.Anything)
}

func TestHelmChartReferencingExternalValues(t *testing.T) {
	service := newService(t, ".")
	spec := v1alpha1.ApplicationSpec{
		Sources: []v1alpha1.ApplicationSource{
			{RepoURL: "https://helm.example.com", Chart: "my-chart", TargetRevision: ">= 1.0.0", Helm: &v1alpha1.ApplicationSourceHelm{
				ValueFiles: []string{"$ref/testdata/my-chart/my-chart-values.yaml"},
			}},
			{Ref: "ref", RepoURL: "https://git.example.com/test/repo"},
		},
	}
	refSources, err := argo.GetRefSources(t.Context(), spec.Sources, spec.Project, func(_ context.Context, _ string, _ string) (*v1alpha1.Repository, error) {
		return &v1alpha1.Repository{
			Repo: "https://git.example.com/test/repo",
		}, nil
	}, []string{})
	require.NoError(t, err)
	request := &apiclient.ManifestRequest{
		Repo: &v1alpha1.Repository{}, ApplicationSource: &spec.Sources[0], NoCache: true, RefSources: refSources, HasMultipleSources: true, ProjectName: "something",
		ProjectSourceRepos: []string{"*"},
	}
	response, err := service.GenerateManifest(t.Context(), request)
	require.NoError(t, err)
	assert.NotNil(t, response)
	assert.Equal(t, &apiclient.ManifestResponse{
		Manifests:  []string{"{\"apiVersion\":\"v1\",\"kind\":\"ConfigMap\",\"metadata\":{\"name\":\"my-map\"}}"},
		Namespace:  "",
		Server:     "",
		Revision:   "1.1.0",
		SourceType: "Helm",
		Commands:   []string{`helm template . --name-template "" --values ./testdata/my-chart/my-chart-values.yaml --include-crds`},
	}, response)
}

func TestHelmChartReferencingExternalValues_InvalidRefs(t *testing.T) {
	spec := v1alpha1.ApplicationSpec{
		Sources: []v1alpha1.ApplicationSource{
			{RepoURL: "https://helm.example.com", Chart: "my-chart", TargetRevision: ">= 1.0.0", Helm: &v1alpha1.ApplicationSourceHelm{
				ValueFiles: []string{"$ref/testdata/my-chart/my-chart-values.yaml"},
			}},
			{RepoURL: "https://git.example.com/test/repo"},
		},
	}

	// Empty refsource
	service := newService(t, ".")

	getRepository := func(_ context.Context, _ string, _ string) (*v1alpha1.Repository, error) {
		return &v1alpha1.Repository{
			Repo: "https://git.example.com/test/repo",
		}, nil
	}

	refSources, err := argo.GetRefSources(t.Context(), spec.Sources, spec.Project, getRepository, []string{})
	require.NoError(t, err)

	request := &apiclient.ManifestRequest{
		Repo: &v1alpha1.Repository{}, ApplicationSource: &spec.Sources[0], NoCache: true, RefSources: refSources, HasMultipleSources: true, ProjectName: "something",
		ProjectSourceRepos: []string{"*"},
	}
	response, err := service.GenerateManifest(t.Context(), request)
	require.Error(t, err)
	assert.Nil(t, response)

	// Invalid ref
	service = newService(t, ".")

	spec.Sources[1].Ref = "Invalid"
	refSources, err = argo.GetRefSources(t.Context(), spec.Sources, spec.Project, getRepository, []string{})
	require.NoError(t, err)

	request = &apiclient.ManifestRequest{
		Repo: &v1alpha1.Repository{}, ApplicationSource: &spec.Sources[0], NoCache: true, RefSources: refSources, HasMultipleSources: true, ProjectName: "something",
		ProjectSourceRepos: []string{"*"},
	}
	response, err = service.GenerateManifest(t.Context(), request)
	require.Error(t, err)
	assert.Nil(t, response)

	// Helm chart as ref (unsupported)
	service = newService(t, ".")

	spec.Sources[1].Ref = "ref"
	spec.Sources[1].Chart = "helm-chart"
	refSources, err = argo.GetRefSources(t.Context(), spec.Sources, spec.Project, getRepository, []string{})
	require.NoError(t, err)

	request = &apiclient.ManifestRequest{
		Repo: &v1alpha1.Repository{}, ApplicationSource: &spec.Sources[0], NoCache: true, RefSources: refSources, HasMultipleSources: true, ProjectName: "something",
		ProjectSourceRepos: []string{"*"},
	}
	response, err = service.GenerateManifest(t.Context(), request)
	require.Error(t, err)
	assert.Nil(t, response)
}

func TestHelmChartReferencingExternalValues_OutOfBounds_Symlink(t *testing.T) {
	service := newService(t, ".")
	err := os.Mkdir("testdata/oob-symlink", 0o755)
	require.NoError(t, err)
	t.Cleanup(func() {
		err = os.RemoveAll("testdata/oob-symlink")
		require.NoError(t, err)
	})
	// Create a symlink to a file outside the repo
	err = os.Symlink("../../../values.yaml", "./testdata/oob-symlink/oob-symlink.yaml")
	// Create a regular file to reference from another source
	err = os.WriteFile("./testdata/oob-symlink/values.yaml", []byte("foo: bar"), 0o644)
	require.NoError(t, err)
	spec := v1alpha1.ApplicationSpec{
		Project: "default",
		Sources: []v1alpha1.ApplicationSource{
			{RepoURL: "https://helm.example.com", Chart: "my-chart", TargetRevision: ">= 1.0.0", Helm: &v1alpha1.ApplicationSourceHelm{
				// Reference `ref` but do not use the oob symlink. The mere existence of the link should be enough to
				// cause an error.
				ValueFiles: []string{"$ref/testdata/oob-symlink/values.yaml"},
			}},
			{Ref: "ref", RepoURL: "https://git.example.com/test/repo"},
		},
	}
	refSources, err := argo.GetRefSources(t.Context(), spec.Sources, spec.Project, func(_ context.Context, _ string, _ string) (*v1alpha1.Repository, error) {
		return &v1alpha1.Repository{
			Repo: "https://git.example.com/test/repo",
		}, nil
	}, []string{})
	require.NoError(t, err)
	request := &apiclient.ManifestRequest{Repo: &v1alpha1.Repository{}, ApplicationSource: &spec.Sources[0], NoCache: true, RefSources: refSources, HasMultipleSources: true}
	_, err = service.GenerateManifest(t.Context(), request)
	require.Error(t, err)
}

func TestGenerateManifestsUseExactRevision(t *testing.T) {
	service, gitClient, _ := newServiceWithMocks(t, ".", false)

	src := v1alpha1.ApplicationSource{Path: "./testdata/recurse", Directory: &v1alpha1.ApplicationSourceDirectory{Recurse: true}}

	q := apiclient.ManifestRequest{
		Repo: &v1alpha1.Repository{}, ApplicationSource: &src, Revision: "abc", ProjectName: "something",
		ProjectSourceRepos: []string{"*"},
	}

	res1, err := service.GenerateManifest(t.Context(), &q)
	require.NoError(t, err)
	assert.Len(t, res1.Manifests, 2)
	assert.Equal(t, "abc", gitClient.Calls[0].Arguments[0])
}

func TestRecurseManifestsInDir(t *testing.T) {
	service := newService(t, ".")

	src := v1alpha1.ApplicationSource{Path: "./testdata/recurse", Directory: &v1alpha1.ApplicationSourceDirectory{Recurse: true}}

	q := apiclient.ManifestRequest{
		Repo: &v1alpha1.Repository{}, ApplicationSource: &src, ProjectName: "something",
		ProjectSourceRepos: []string{"*"},
	}

	res1, err := service.GenerateManifest(t.Context(), &q)
	require.NoError(t, err)
	assert.Len(t, res1.Manifests, 2)
}

func TestInvalidManifestsInDir(t *testing.T) {
	service := newService(t, ".")

	src := v1alpha1.ApplicationSource{Path: "./testdata/invalid-manifests", Directory: &v1alpha1.ApplicationSourceDirectory{Recurse: true}}

	q := apiclient.ManifestRequest{Repo: &v1alpha1.Repository{}, ApplicationSource: &src}

	_, err := service.GenerateManifest(t.Context(), &q)
	require.Error(t, err)
}

func TestSkippedInvalidManifestsInDir(t *testing.T) {
	service := newService(t, ".")

	src := v1alpha1.ApplicationSource{Path: "./testdata/invalid-manifests-skipped", Directory: &v1alpha1.ApplicationSourceDirectory{Recurse: true}}

	q := apiclient.ManifestRequest{Repo: &v1alpha1.Repository{}, ApplicationSource: &src}

	_, err := service.GenerateManifest(t.Context(), &q)
	require.NoError(t, err)
}

func TestInvalidMetadata(t *testing.T) {
	service := newService(t, ".")

	src := v1alpha1.ApplicationSource{Path: "./testdata/invalid-metadata", Directory: &v1alpha1.ApplicationSourceDirectory{Recurse: true}}
	q := apiclient.ManifestRequest{Repo: &v1alpha1.Repository{}, ApplicationSource: &src, AppLabelKey: "test", AppName: "invalid-metadata", TrackingMethod: "annotation+label"}
	_, err := service.GenerateManifest(t.Context(), &q)
	assert.ErrorContains(t, err, "contains non-string value in the map under key \"invalid\"")
}

func TestNilMetadataAccessors(t *testing.T) {
	service := newService(t, ".")
	expected := "{\"apiVersion\":\"v1\",\"kind\":\"ConfigMap\",\"metadata\":{\"annotations\":{\"argocd.argoproj.io/tracking-id\":\"nil-metadata-accessors:/ConfigMap:/my-map\"},\"labels\":{\"test\":\"nil-metadata-accessors\"},\"name\":\"my-map\"},\"stringData\":{\"foo\":\"bar\"}}"

	src := v1alpha1.ApplicationSource{Path: "./testdata/nil-metadata-accessors", Directory: &v1alpha1.ApplicationSourceDirectory{Recurse: true}}
	q := apiclient.ManifestRequest{Repo: &v1alpha1.Repository{}, ApplicationSource: &src, AppLabelKey: "test", AppName: "nil-metadata-accessors", TrackingMethod: "annotation+label"}
	res, err := service.GenerateManifest(t.Context(), &q)
	require.NoError(t, err)
	assert.Len(t, res.Manifests, 1)
	assert.Equal(t, expected, res.Manifests[0])
}

func TestGenerateJsonnetManifestInDir(t *testing.T) {
	service := newService(t, ".")

	q := apiclient.ManifestRequest{
		Repo: &v1alpha1.Repository{},
		ApplicationSource: &v1alpha1.ApplicationSource{
			Path: "./testdata/jsonnet",
			Directory: &v1alpha1.ApplicationSourceDirectory{
				Jsonnet: v1alpha1.ApplicationSourceJsonnet{
					ExtVars: []v1alpha1.JsonnetVar{{Name: "extVarString", Value: "extVarString"}, {Name: "extVarCode", Value: "\"extVarCode\"", Code: true}},
					TLAs:    []v1alpha1.JsonnetVar{{Name: "tlaString", Value: "tlaString"}, {Name: "tlaCode", Value: "\"tlaCode\"", Code: true}},
					Libs:    []string{"testdata/jsonnet/vendor"},
				},
			},
		},
		ProjectName:        "something",
		ProjectSourceRepos: []string{"*"},
	}
	res1, err := service.GenerateManifest(t.Context(), &q)
	require.NoError(t, err)
	assert.Len(t, res1.Manifests, 2)
}

func TestGenerateJsonnetManifestInRootDir(t *testing.T) {
	service := newService(t, "testdata/jsonnet-1")

	q := apiclient.ManifestRequest{
		Repo: &v1alpha1.Repository{},
		ApplicationSource: &v1alpha1.ApplicationSource{
			Path: ".",
			Directory: &v1alpha1.ApplicationSourceDirectory{
				Jsonnet: v1alpha1.ApplicationSourceJsonnet{
					ExtVars: []v1alpha1.JsonnetVar{{Name: "extVarString", Value: "extVarString"}, {Name: "extVarCode", Value: "\"extVarCode\"", Code: true}},
					TLAs:    []v1alpha1.JsonnetVar{{Name: "tlaString", Value: "tlaString"}, {Name: "tlaCode", Value: "\"tlaCode\"", Code: true}},
					Libs:    []string{"."},
				},
			},
		},
		ProjectName:        "something",
		ProjectSourceRepos: []string{"*"},
	}
	res1, err := service.GenerateManifest(t.Context(), &q)
	require.NoError(t, err)
	assert.Len(t, res1.Manifests, 2)
}

func TestGenerateJsonnetLibOutside(t *testing.T) {
	service := newService(t, ".")

	q := apiclient.ManifestRequest{
		Repo: &v1alpha1.Repository{},
		ApplicationSource: &v1alpha1.ApplicationSource{
			Path: "./testdata/jsonnet",
			Directory: &v1alpha1.ApplicationSourceDirectory{
				Jsonnet: v1alpha1.ApplicationSourceJsonnet{
					Libs: []string{"../../../testdata/jsonnet/vendor"},
				},
			},
		},
		ProjectName:        "something",
		ProjectSourceRepos: []string{"*"},
	}
	_, err := service.GenerateManifest(t.Context(), &q)
	require.ErrorContains(t, err, "file '../../../testdata/jsonnet/vendor' resolved to outside repository root")
}

func TestManifestGenErrorCacheByNumRequests(t *testing.T) {
	// Returns the state of the manifest generation cache, by querying the cache for the previously set result
	getRecentCachedEntry := func(service *Service, manifestRequest *apiclient.ManifestRequest) *cache.CachedManifestResponse {
		assert.NotNil(t, service)
		assert.NotNil(t, manifestRequest)

		cachedManifestResponse := &cache.CachedManifestResponse{}
		err := service.cache.GetManifests(mock.Anything, manifestRequest.ApplicationSource, manifestRequest.RefSources, manifestRequest, manifestRequest.Namespace, "", manifestRequest.AppLabelKey, manifestRequest.AppName, cachedManifestResponse, nil, "")
		require.NoError(t, err)
		return cachedManifestResponse
	}

	// Example:
	// With repo server (test) parameters:
	// - PauseGenerationAfterFailedGenerationAttempts: 2
	// - PauseGenerationOnFailureForRequests: 4
	// - TotalCacheInvocations: 10
	//
	// After 2 manifest generation failures in a row, the next 4 manifest generation requests should be cached,
	// with the next 2 after that being uncached. Here's how it looks...
	//
	//  request count) result
	// --------------------------
	// 1) Attempt to generate manifest, fails.
	// 2) Second attempt to generate manifest, fails.
	// 3) Return cached error attempt from #2
	// 4) Return cached error attempt from #2
	// 5) Return cached error attempt from #2
	// 6) Return cached error attempt from #2. Max response limit hit, so reset cache entry.
	// 7) Attempt to generate manifest, fails.
	// 8) Attempt to generate manifest, fails.
	// 9) Return cached error attempt from #8
	// 10) Return cached error attempt from #8

	// The same pattern PauseGenerationAfterFailedGenerationAttempts generation attempts, followed by
	// PauseGenerationOnFailureForRequests cached responses, should apply for various combinations of
	// both parameters.

	tests := []struct {
		PauseGenerationAfterFailedGenerationAttempts int
		PauseGenerationOnFailureForRequests          int
		TotalCacheInvocations                        int
	}{
		{2, 4, 10},
		{3, 5, 10},
		{1, 2, 5},
	}
	for _, tt := range tests {
		testName := fmt.Sprintf("gen-attempts-%d-pause-%d-total-%d", tt.PauseGenerationAfterFailedGenerationAttempts, tt.PauseGenerationOnFailureForRequests, tt.TotalCacheInvocations)
		t.Run(testName, func(t *testing.T) {
			service := newService(t, ".")

			service.initConstants = RepoServerInitConstants{
				ParallelismLimit: 1,
				PauseGenerationAfterFailedGenerationAttempts: tt.PauseGenerationAfterFailedGenerationAttempts,
				PauseGenerationOnFailureForMinutes:           0,
				PauseGenerationOnFailureForRequests:          tt.PauseGenerationOnFailureForRequests,
			}

			totalAttempts := service.initConstants.PauseGenerationAfterFailedGenerationAttempts + service.initConstants.PauseGenerationOnFailureForRequests

			for invocationCount := 0; invocationCount < tt.TotalCacheInvocations; invocationCount++ {
				adjustedInvocation := invocationCount % totalAttempts

				fmt.Printf("%d )-------------------------------------------\n", invocationCount)

				manifestRequest := &apiclient.ManifestRequest{
					Repo:    &v1alpha1.Repository{},
					AppName: "test",
					ApplicationSource: &v1alpha1.ApplicationSource{
						Path: "./testdata/invalid-helm",
					},
				}

				res, err := service.GenerateManifest(t.Context(), manifestRequest)

				// Verify invariant: res != nil xor err != nil
				if err != nil {
					assert.Nil(t, res, "both err and res are non-nil res: %v   err: %v", res, err)
				} else {
					assert.NotNil(t, res, "both err and res are nil")
				}

				cachedManifestResponse := getRecentCachedEntry(service, manifestRequest)

				isCachedError := err != nil && strings.HasPrefix(err.Error(), cachedManifestGenerationPrefix)

				if adjustedInvocation < service.initConstants.PauseGenerationAfterFailedGenerationAttempts {
					// GenerateManifest should not return cached errors for the first X responses, where X is the FailGenAttempts constants
					require.False(t, isCachedError)

					require.NotNil(t, cachedManifestResponse)
					assert.Nil(t, cachedManifestResponse.ManifestResponse)
					assert.NotEqual(t, 0, cachedManifestResponse.FirstFailureTimestamp)

					// Internal cache consec failures value should increase with invocations, cached response should stay the same,
					assert.Equal(t, cachedManifestResponse.NumberOfConsecutiveFailures, adjustedInvocation+1)
					assert.Equal(t, 0, cachedManifestResponse.NumberOfCachedResponsesReturned)
				} else {
					// GenerateManifest SHOULD return cached errors for the next X responses, where X is the
					// PauseGenerationOnFailureForRequests constant
					assert.True(t, isCachedError)
					require.NotNil(t, cachedManifestResponse)
					assert.Nil(t, cachedManifestResponse.ManifestResponse)
					assert.NotEqual(t, 0, cachedManifestResponse.FirstFailureTimestamp)

					// Internal cache values should update correctly based on number of return cache entries, consecutive failures should stay the same
					assert.Equal(t, cachedManifestResponse.NumberOfConsecutiveFailures, service.initConstants.PauseGenerationAfterFailedGenerationAttempts)
					assert.Equal(t, cachedManifestResponse.NumberOfCachedResponsesReturned, (adjustedInvocation - service.initConstants.PauseGenerationAfterFailedGenerationAttempts + 1))
				}
			}
		})
	}
}

func TestManifestGenErrorCacheFileContentsChange(t *testing.T) {
	tmpDir := t.TempDir()

	service := newService(t, tmpDir)

	service.initConstants = RepoServerInitConstants{
		ParallelismLimit: 1,
		PauseGenerationAfterFailedGenerationAttempts: 2,
		PauseGenerationOnFailureForMinutes:           0,
		PauseGenerationOnFailureForRequests:          4,
	}

	for step := range 3 {
		// step 1) Attempt to generate manifests against invalid helm chart (should return uncached error)
		// step 2) Attempt to generate manifest against valid helm chart (should succeed and return valid response)
		// step 3) Attempt to generate manifest against invalid helm chart (should return cached value from step 2)

		errorExpected := step%2 == 0

		// Ensure that the target directory will succeed or fail, so we can verify the cache correctly handles it
		err := os.RemoveAll(tmpDir)
		require.NoError(t, err)
		err = os.MkdirAll(tmpDir, 0o777)
		require.NoError(t, err)
		if errorExpected {
			// Copy invalid helm chart into temporary directory, ensuring manifest generation will fail
			err = fileutil.CopyDir("./testdata/invalid-helm", tmpDir)
			require.NoError(t, err)
		} else {
			// Copy valid helm chart into temporary directory, ensuring generation will succeed
			err = fileutil.CopyDir("./testdata/my-chart", tmpDir)
			require.NoError(t, err)
		}

		res, err := service.GenerateManifest(t.Context(), &apiclient.ManifestRequest{
			Repo:    &v1alpha1.Repository{},
			AppName: "test",
			ApplicationSource: &v1alpha1.ApplicationSource{
				Path: ".",
			},
			ProjectName:        "something",
			ProjectSourceRepos: []string{"*"},
		})

		fmt.Println("-", step, "-", res != nil, err != nil, errorExpected)
		fmt.Println("    err: ", err)
		fmt.Println("    res: ", res)

		if step < 2 {
			if errorExpected {
				require.Error(t, err, "error return value and error expected did not match")
				assert.Nil(t, res, "GenerateManifest return value and expected value did not match")
			} else {
				require.NoError(t, err, "error return value and error expected did not match")
				assert.NotNil(t, res, "GenerateManifest return value and expected value did not match")
			}
		}

		if step == 2 {
			require.NoError(t, err, "error ret val was non-nil on step 3")
			assert.NotNil(t, res, "GenerateManifest ret val was nil on step 3")
		}
	}
}

func TestManifestGenErrorCacheByMinutesElapsed(t *testing.T) {
	tests := []struct {
		// Test with a range of pause expiration thresholds
		PauseGenerationOnFailureForMinutes int
	}{
		{1}, {2}, {10}, {24 * 60},
	}
	for _, tt := range tests {
		testName := fmt.Sprintf("pause-time-%d", tt.PauseGenerationOnFailureForMinutes)
		t.Run(testName, func(t *testing.T) {
			service := newService(t, ".")

			// Here we simulate the passage of time by overriding the now() function of Service
			currentTime := time.Now()
			service.now = func() time.Time {
				return currentTime
			}

			service.initConstants = RepoServerInitConstants{
				ParallelismLimit: 1,
				PauseGenerationAfterFailedGenerationAttempts: 1,
				PauseGenerationOnFailureForMinutes:           tt.PauseGenerationOnFailureForMinutes,
				PauseGenerationOnFailureForRequests:          0,
			}

			// 1) Put the cache into the failure state
			for x := range 2 {
				res, err := service.GenerateManifest(t.Context(), &apiclient.ManifestRequest{
					Repo:    &v1alpha1.Repository{},
					AppName: "test",
					ApplicationSource: &v1alpha1.ApplicationSource{
						Path: "./testdata/invalid-helm",
					},
				})

				assert.True(t, err != nil && res == nil)

				// Ensure that the second invocation triggers the cached error state
				if x == 1 {
					assert.True(t, strings.HasPrefix(err.Error(), cachedManifestGenerationPrefix))
				}
			}

			// 2) Jump forward X-1 minutes in time, where X is the expiration boundary
			currentTime = currentTime.Add(time.Duration(tt.PauseGenerationOnFailureForMinutes-1) * time.Minute)
			res, err := service.GenerateManifest(t.Context(), &apiclient.ManifestRequest{
				Repo:    &v1alpha1.Repository{},
				AppName: "test",
				ApplicationSource: &v1alpha1.ApplicationSource{
					Path: "./testdata/invalid-helm",
				},
			})

			// 3) Ensure that the cache still returns a cached copy of the last error
			assert.True(t, err != nil && res == nil)
			assert.True(t, strings.HasPrefix(err.Error(), cachedManifestGenerationPrefix))

			// 4) Jump forward 2 minutes in time, such that the pause generation time has elapsed and we should return to normal state
			currentTime = currentTime.Add(2 * time.Minute)

			res, err = service.GenerateManifest(t.Context(), &apiclient.ManifestRequest{
				Repo:    &v1alpha1.Repository{},
				AppName: "test",
				ApplicationSource: &v1alpha1.ApplicationSource{
					Path: "./testdata/invalid-helm",
				},
			})

			// 5) Ensure that the service no longer returns a cached copy of the last error
			assert.True(t, err != nil && res == nil)
			assert.False(t, strings.HasPrefix(err.Error(), cachedManifestGenerationPrefix))
		})
	}
}

func TestManifestGenErrorCacheRespectsNoCache(t *testing.T) {
	service := newService(t, ".")

	service.initConstants = RepoServerInitConstants{
		ParallelismLimit: 1,
		PauseGenerationAfterFailedGenerationAttempts: 1,
		PauseGenerationOnFailureForMinutes:           0,
		PauseGenerationOnFailureForRequests:          4,
	}

	// 1) Put the cache into the failure state
	for x := range 2 {
		res, err := service.GenerateManifest(t.Context(), &apiclient.ManifestRequest{
			Repo:    &v1alpha1.Repository{},
			AppName: "test",
			ApplicationSource: &v1alpha1.ApplicationSource{
				Path: "./testdata/invalid-helm",
			},
		})

		assert.True(t, err != nil && res == nil)

		// Ensure that the second invocation is cached
		if x == 1 {
			assert.True(t, strings.HasPrefix(err.Error(), cachedManifestGenerationPrefix))
		}
	}

	// 2) Call generateManifest with NoCache enabled
	res, err := service.GenerateManifest(t.Context(), &apiclient.ManifestRequest{
		Repo:    &v1alpha1.Repository{},
		AppName: "test",
		ApplicationSource: &v1alpha1.ApplicationSource{
			Path: "./testdata/invalid-helm",
		},
		NoCache: true,
	})

	// 3) Ensure that the cache returns a new generation attempt, rather than a previous cached error
	assert.True(t, err != nil && res == nil)
	assert.False(t, strings.HasPrefix(err.Error(), cachedManifestGenerationPrefix))

	// 4) Call generateManifest
	res, err = service.GenerateManifest(t.Context(), &apiclient.ManifestRequest{
		Repo:    &v1alpha1.Repository{},
		AppName: "test",
		ApplicationSource: &v1alpha1.ApplicationSource{
			Path: "./testdata/invalid-helm",
		},
	})

	// 5) Ensure that the subsequent invocation, after nocache, is cached
	assert.True(t, err != nil && res == nil)
	assert.True(t, strings.HasPrefix(err.Error(), cachedManifestGenerationPrefix))
}

func TestGenerateHelmKubeVersion(t *testing.T) {
	service := newService(t, "../../util/helm/testdata/redis")

	res, err := service.GenerateManifest(t.Context(), &apiclient.ManifestRequest{
		Repo:    &v1alpha1.Repository{},
		AppName: "test",
		ApplicationSource: &v1alpha1.ApplicationSource{
			Path: ".",
			Helm: &v1alpha1.ApplicationSourceHelm{
				KubeVersion: "1.30.11+IKS",
			},
		},
		ProjectName:        "something",
		ProjectSourceRepos: []string{"*"},
	})

	require.NoError(t, err)
	assert.Len(t, res.Commands, 1)
	assert.Contains(t, res.Commands[0], "--kube-version 1.30.11")
}

func TestGenerateHelmWithValues(t *testing.T) {
	service := newService(t, "../../util/helm/testdata/redis")

	res, err := service.GenerateManifest(t.Context(), &apiclient.ManifestRequest{
		Repo:    &v1alpha1.Repository{},
		AppName: "test",
		ApplicationSource: &v1alpha1.ApplicationSource{
			Path: ".",
			Helm: &v1alpha1.ApplicationSourceHelm{
				ValueFiles:   []string{"values-production.yaml"},
				ValuesObject: &runtime.RawExtension{Raw: []byte(`cluster: {slaveCount: 2}`)},
			},
		},
		ProjectName:        "something",
		ProjectSourceRepos: []string{"*"},
	})

	require.NoError(t, err)

	replicasVerified := false
	for _, src := range res.Manifests {
		obj := unstructured.Unstructured{}
		err = json.Unmarshal([]byte(src), &obj)
		require.NoError(t, err)

		if obj.GetKind() == "Deployment" && obj.GetName() == "test-redis-slave" {
			var dep appsv1.Deployment
			err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, &dep)
			require.NoError(t, err)
			assert.Equal(t, int32(2), *dep.Spec.Replicas)
			replicasVerified = true
		}
	}
	assert.True(t, replicasVerified)
}

func TestHelmWithMissingValueFiles(t *testing.T) {
	service := newService(t, "../../util/helm/testdata/redis")
	missingValuesFile := "values-prod-overrides.yaml"

	req := &apiclient.ManifestRequest{
		Repo:    &v1alpha1.Repository{},
		AppName: "test",
		ApplicationSource: &v1alpha1.ApplicationSource{
			Path: ".",
			Helm: &v1alpha1.ApplicationSourceHelm{
				ValueFiles: []string{"values-production.yaml", missingValuesFile},
			},
		},
		ProjectName:        "something",
		ProjectSourceRepos: []string{"*"},
	}

	// Should fail since we're passing a non-existent values file, and error should indicate that
	_, err := service.GenerateManifest(t.Context(), req)
	require.ErrorContains(t, err, missingValuesFile+": no such file or directory")

	// Should template without error even if defining a non-existent values file
	req.ApplicationSource.Helm.IgnoreMissingValueFiles = true
	_, err = service.GenerateManifest(t.Context(), req)
	require.NoError(t, err)
}

func TestGenerateHelmWithEnvVars(t *testing.T) {
	service := newService(t, "../../util/helm/testdata/redis")

	res, err := service.GenerateManifest(t.Context(), &apiclient.ManifestRequest{
		Repo:    &v1alpha1.Repository{},
		AppName: "production",
		ApplicationSource: &v1alpha1.ApplicationSource{
			Path: ".",
			Helm: &v1alpha1.ApplicationSourceHelm{
				ValueFiles: []string{"values-$ARGOCD_APP_NAME.yaml"},
			},
		},
		ProjectName:        "something",
		ProjectSourceRepos: []string{"*"},
	})

	require.NoError(t, err)

	replicasVerified := false
	for _, src := range res.Manifests {
		obj := unstructured.Unstructured{}
		err = json.Unmarshal([]byte(src), &obj)
		require.NoError(t, err)

		if obj.GetKind() == "Deployment" && obj.GetName() == "production-redis-slave" {
			var dep appsv1.Deployment
			err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, &dep)
			require.NoError(t, err)
			assert.Equal(t, int32(3), *dep.Spec.Replicas)
			replicasVerified = true
		}
	}
	assert.True(t, replicasVerified)
}

// The requested value file (`../minio/values.yaml`) is outside the app path (`./util/helm/testdata/redis`), however
// since the requested value is still under the repo directory (`~/go/src/github.com/argoproj/argo-cd`), it is allowed
func TestGenerateHelmWithValuesDirectoryTraversal(t *testing.T) {
	service := newService(t, "../../util/helm/testdata")
	_, err := service.GenerateManifest(t.Context(), &apiclient.ManifestRequest{
		Repo:    &v1alpha1.Repository{},
		AppName: "test",
		ApplicationSource: &v1alpha1.ApplicationSource{
			Path: "./redis",
			Helm: &v1alpha1.ApplicationSourceHelm{
				ValueFiles:   []string{"../minio/values.yaml"},
				ValuesObject: &runtime.RawExtension{Raw: []byte(`cluster: {slaveCount: 2}`)},
			},
		},
		ProjectName:        "something",
		ProjectSourceRepos: []string{"*"},
	})
	require.NoError(t, err)

	// Test the case where the path is "."
	service = newService(t, "./testdata")
	_, err = service.GenerateManifest(t.Context(), &apiclient.ManifestRequest{
		Repo:    &v1alpha1.Repository{},
		AppName: "test",
		ApplicationSource: &v1alpha1.ApplicationSource{
			Path: "./my-chart",
		},
		ProjectName:        "something",
		ProjectSourceRepos: []string{"*"},
	})
	require.NoError(t, err)
}

func TestChartRepoWithOutOfBoundsSymlink(t *testing.T) {
	service := newService(t, ".")
	source := &v1alpha1.ApplicationSource{Chart: "out-of-bounds-chart", TargetRevision: ">= 1.0.0"}
	request := &apiclient.ManifestRequest{Repo: &v1alpha1.Repository{}, ApplicationSource: source, NoCache: true}
	_, err := service.GenerateManifest(t.Context(), request)
	assert.ErrorContains(t, err, "chart contains out-of-bounds symlinks")
}

// This is a Helm first-class app with a values file inside the repo directory
// (`~/go/src/github.com/argoproj/argo-cd/reposerver/repository`), so it is allowed
func TestHelmManifestFromChartRepoWithValueFile(t *testing.T) {
	service := newService(t, ".")
	source := &v1alpha1.ApplicationSource{
		Chart:          "my-chart",
		TargetRevision: ">= 1.0.0",
		Helm: &v1alpha1.ApplicationSourceHelm{
			ValueFiles: []string{"./my-chart-values.yaml"},
		},
	}
	request := &apiclient.ManifestRequest{
		Repo:               &v1alpha1.Repository{},
		ApplicationSource:  source,
		NoCache:            true,
		ProjectName:        "something",
		ProjectSourceRepos: []string{"*"},
	}
	response, err := service.GenerateManifest(t.Context(), request)
	require.NoError(t, err)
	assert.NotNil(t, response)
	assert.Equal(t, &apiclient.ManifestResponse{
		Manifests:  []string{"{\"apiVersion\":\"v1\",\"kind\":\"ConfigMap\",\"metadata\":{\"name\":\"my-map\"}}"},
		Namespace:  "",
		Server:     "",
		Revision:   "1.1.0",
		SourceType: "Helm",
		Commands:   []string{`helm template . --name-template "" --values ./testdata/my-chart/my-chart-values.yaml --include-crds`},
	}, response)
}

// This is a Helm first-class app with a values file outside the repo directory
// (`~/go/src/github.com/argoproj/argo-cd/reposerver/repository`), so it is not allowed
func TestHelmManifestFromChartRepoWithValueFileOutsideRepo(t *testing.T) {
	service := newService(t, ".")
	source := &v1alpha1.ApplicationSource{
		Chart:          "my-chart",
		TargetRevision: ">= 1.0.0",
		Helm: &v1alpha1.ApplicationSourceHelm{
			ValueFiles: []string{"../my-chart-2/my-chart-2-values.yaml"},
		},
	}
	request := &apiclient.ManifestRequest{Repo: &v1alpha1.Repository{}, ApplicationSource: source, NoCache: true}
	_, err := service.GenerateManifest(t.Context(), request)
	require.Error(t, err)
}

func TestHelmManifestFromChartRepoWithValueFileLinks(t *testing.T) {
	t.Run("Valid symlink", func(t *testing.T) {
		service := newService(t, ".")
		source := &v1alpha1.ApplicationSource{
			Chart:          "my-chart",
			TargetRevision: ">= 1.0.0",
			Helm: &v1alpha1.ApplicationSourceHelm{
				ValueFiles: []string{"my-chart-link.yaml"},
			},
		}
		request := &apiclient.ManifestRequest{
			Repo: &v1alpha1.Repository{}, ApplicationSource: source, NoCache: true, ProjectName: "something",
			ProjectSourceRepos: []string{"*"},
		}
		_, err := service.GenerateManifest(t.Context(), request)
		require.NoError(t, err)
	})
}

func TestGenerateHelmWithURL(t *testing.T) {
	service := newService(t, "../../util/helm/testdata/redis")

	_, err := service.GenerateManifest(t.Context(), &apiclient.ManifestRequest{
		Repo:    &v1alpha1.Repository{},
		AppName: "test",
		ApplicationSource: &v1alpha1.ApplicationSource{
			Path: ".",
			Helm: &v1alpha1.ApplicationSourceHelm{
				ValueFiles:   []string{"https://raw.githubusercontent.com/argoproj/argocd-example-apps/master/helm-guestbook/values.yaml"},
				ValuesObject: &runtime.RawExtension{Raw: []byte(`cluster: {slaveCount: 2}`)},
			},
		},
		ProjectName:        "something",
		ProjectSourceRepos: []string{"*"},
		HelmOptions:        &v1alpha1.HelmOptions{ValuesFileSchemes: []string{"https"}},
	})
	require.NoError(t, err)
}

// The requested value file (`../minio/values.yaml`) is outside the repo directory
// (`~/go/src/github.com/argoproj/argo-cd/util/helm/testdata/redis`), so it is blocked
func TestGenerateHelmWithValuesDirectoryTraversalOutsideRepo(t *testing.T) {
	t.Run("Values file with relative path pointing outside repo root", func(t *testing.T) {
		service := newService(t, "../../util/helm/testdata/redis")
		_, err := service.GenerateManifest(t.Context(), &apiclient.ManifestRequest{
			Repo:    &v1alpha1.Repository{},
			AppName: "test",
			ApplicationSource: &v1alpha1.ApplicationSource{
				Path: ".",
				Helm: &v1alpha1.ApplicationSourceHelm{
					ValueFiles:   []string{"../minio/values.yaml"},
					ValuesObject: &runtime.RawExtension{Raw: []byte(`cluster: {slaveCount: 2}`)},
				},
			},
			ProjectName:        "something",
			ProjectSourceRepos: []string{"*"},
		})
		assert.ErrorContains(t, err, "outside repository root")
	})

	t.Run("Values file with relative path pointing inside repo root", func(t *testing.T) {
		service := newService(t, "./testdata")
		_, err := service.GenerateManifest(t.Context(), &apiclient.ManifestRequest{
			Repo:    &v1alpha1.Repository{},
			AppName: "test",
			ApplicationSource: &v1alpha1.ApplicationSource{
				Path: "./my-chart",
				Helm: &v1alpha1.ApplicationSourceHelm{
					ValueFiles:   []string{"../my-chart/my-chart-values.yaml"},
					ValuesObject: &runtime.RawExtension{Raw: []byte(`cluster: {slaveCount: 2}`)},
				},
			},
			ProjectName:        "something",
			ProjectSourceRepos: []string{"*"},
		})
		require.NoError(t, err)
	})

	t.Run("Values file with absolute path stays within repo root", func(t *testing.T) {
		service := newService(t, "./testdata")
		_, err := service.GenerateManifest(t.Context(), &apiclient.ManifestRequest{
			Repo:    &v1alpha1.Repository{},
			AppName: "test",
			ApplicationSource: &v1alpha1.ApplicationSource{
				Path: "./my-chart",
				Helm: &v1alpha1.ApplicationSourceHelm{
					ValueFiles:   []string{"/my-chart/my-chart-values.yaml"},
					ValuesObject: &runtime.RawExtension{Raw: []byte(`cluster: {slaveCount: 2}`)},
				},
			},
			ProjectName:        "something",
			ProjectSourceRepos: []string{"*"},
		})
		require.NoError(t, err)
	})

	t.Run("Values file with absolute path using back-references outside repo root", func(t *testing.T) {
		service := newService(t, "./testdata")
		_, err := service.GenerateManifest(t.Context(), &apiclient.ManifestRequest{
			Repo:    &v1alpha1.Repository{},
			AppName: "test",
			ApplicationSource: &v1alpha1.ApplicationSource{
				Path: "./my-chart",
				Helm: &v1alpha1.ApplicationSourceHelm{
					ValueFiles:   []string{"/../../../my-chart-values.yaml"},
					ValuesObject: &runtime.RawExtension{Raw: []byte(`cluster: {slaveCount: 2}`)},
				},
			},
			ProjectName:        "something",
			ProjectSourceRepos: []string{"*"},
		})
		assert.ErrorContains(t, err, "outside repository root")
	})

	t.Run("Remote values file from forbidden protocol", func(t *testing.T) {
		service := newService(t, "./testdata")
		_, err := service.GenerateManifest(t.Context(), &apiclient.ManifestRequest{
			Repo:    &v1alpha1.Repository{},
			AppName: "test",
			ApplicationSource: &v1alpha1.ApplicationSource{
				Path: "./my-chart",
				Helm: &v1alpha1.ApplicationSourceHelm{
					ValueFiles:   []string{"file://../../../../my-chart-values.yaml"},
					ValuesObject: &runtime.RawExtension{Raw: []byte(`cluster: {slaveCount: 2}`)},
				},
			},
			ProjectName:        "something",
			ProjectSourceRepos: []string{"*"},
		})
		assert.ErrorContains(t, err, "is not allowed")
	})

	t.Run("Remote values file from custom allowed protocol", func(t *testing.T) {
		service := newService(t, "./testdata")
		_, err := service.GenerateManifest(t.Context(), &apiclient.ManifestRequest{
			Repo:    &v1alpha1.Repository{},
			AppName: "test",
			ApplicationSource: &v1alpha1.ApplicationSource{
				Path: "./my-chart",
				Helm: &v1alpha1.ApplicationSourceHelm{
					ValueFiles: []string{"s3://my-bucket/my-chart-values.yaml"},
				},
			},
			HelmOptions:        &v1alpha1.HelmOptions{ValuesFileSchemes: []string{"s3"}},
			ProjectName:        "something",
			ProjectSourceRepos: []string{"*"},
		})
		assert.ErrorContains(t, err, "s3://my-bucket/my-chart-values.yaml: no such file or directory")
	})
}

// File parameter should not allow traversal outside of the repository root
func TestGenerateHelmWithAbsoluteFileParameter(t *testing.T) {
	service := newService(t, "../..")

	file, err := os.CreateTemp(t.TempDir(), "external-secret.txt")
	require.NoError(t, err)
	externalSecretPath := file.Name()
	defer func() { _ = os.RemoveAll(externalSecretPath) }()
	expectedFileContent, err := os.ReadFile("../../util/helm/testdata/external/external-secret.txt")
	require.NoError(t, err)
	err = os.WriteFile(externalSecretPath, expectedFileContent, 0o644)
	require.NoError(t, err)
	defer func() {
		if err = file.Close(); err != nil {
			panic(err)
		}
	}()

	_, err = service.GenerateManifest(t.Context(), &apiclient.ManifestRequest{
		Repo:    &v1alpha1.Repository{},
		AppName: "test",
		ApplicationSource: &v1alpha1.ApplicationSource{
			Path: "./util/helm/testdata/redis",
			Helm: &v1alpha1.ApplicationSourceHelm{
				ValueFiles:   []string{"values-production.yaml"},
				ValuesObject: &runtime.RawExtension{Raw: []byte(`cluster: {slaveCount: 2}`)},
				FileParameters: []v1alpha1.HelmFileParameter{{
					Name: "passwordContent",
					Path: externalSecretPath,
				}},
			},
		},
		ProjectName:        "something",
		ProjectSourceRepos: []string{"*"},
	})
	require.Error(t, err)
}

// The requested file parameter (`../external/external-secret.txt`) is outside the app path
// (`./util/helm/testdata/redis`), however since the requested value is still under the repo
// directory (`~/go/src/github.com/argoproj/argo-cd`), it is allowed. It is used as a means of
// providing direct content to a helm chart via a specific key.
func TestGenerateHelmWithFileParameter(t *testing.T) {
	service := newService(t, "../../util/helm/testdata")

	res, err := service.GenerateManifest(t.Context(), &apiclient.ManifestRequest{
		Repo:    &v1alpha1.Repository{},
		AppName: "test",
		ApplicationSource: &v1alpha1.ApplicationSource{
			Path: "./redis",
			Helm: &v1alpha1.ApplicationSourceHelm{
				ValueFiles:   []string{"values-production.yaml"},
				Values:       `cluster: {slaveCount: 10}`,
				ValuesObject: &runtime.RawExtension{Raw: []byte(`cluster: {slaveCount: 2}`)},
				FileParameters: []v1alpha1.HelmFileParameter{{
					Name: "passwordContent",
					Path: "../external/external-secret.txt",
				}},
			},
		},
		ProjectName:        "something",
		ProjectSourceRepos: []string{"*"},
	})
	require.NoError(t, err)
	assert.Contains(t, res.Manifests[6], `"replicas":2`, "ValuesObject should override Values")
}

func TestGenerateNullList(t *testing.T) {
	service := newService(t, ".")

	t.Run("null list", func(t *testing.T) {
		res1, err := service.GenerateManifest(t.Context(), &apiclient.ManifestRequest{
			Repo:               &v1alpha1.Repository{},
			ApplicationSource:  &v1alpha1.ApplicationSource{Path: "./testdata/null-list"},
			NoCache:            true,
			ProjectName:        "something",
			ProjectSourceRepos: []string{"*"},
		})
		require.NoError(t, err)
		assert.Len(t, res1.Manifests, 1)
		assert.Contains(t, res1.Manifests[0], "prometheus-operator-operator")
	})

	t.Run("empty list", func(t *testing.T) {
		res1, err := service.GenerateManifest(t.Context(), &apiclient.ManifestRequest{
			Repo:               &v1alpha1.Repository{},
			ApplicationSource:  &v1alpha1.ApplicationSource{Path: "./testdata/empty-list"},
			NoCache:            true,
			ProjectName:        "something",
			ProjectSourceRepos: []string{"*"},
		})
		require.NoError(t, err)
		assert.Len(t, res1.Manifests, 1)
		assert.Contains(t, res1.Manifests[0], "prometheus-operator-operator")
	})

	t.Run("weird list", func(t *testing.T) {
		res1, err := service.GenerateManifest(t.Context(), &apiclient.ManifestRequest{
			Repo:               &v1alpha1.Repository{},
			ApplicationSource:  &v1alpha1.ApplicationSource{Path: "./testdata/weird-list"},
			NoCache:            true,
			ProjectName:        "something",
			ProjectSourceRepos: []string{"*"},
		})
		require.NoError(t, err)
		assert.Len(t, res1.Manifests, 2)
	})
}

func TestIdentifyAppSourceTypeByAppDirWithKustomizations(t *testing.T) {
	sourceType, err := GetAppSourceType(t.Context(), &v1alpha1.ApplicationSource{}, "./testdata/kustomization_yaml", "./testdata", "testapp", map[string]bool{}, []string{}, []string{})
	require.NoError(t, err)
	assert.Equal(t, v1alpha1.ApplicationSourceTypeKustomize, sourceType)

	sourceType, err = GetAppSourceType(t.Context(), &v1alpha1.ApplicationSource{}, "./testdata/kustomization_yml", "./testdata", "testapp", map[string]bool{}, []string{}, []string{})
	require.NoError(t, err)
	assert.Equal(t, v1alpha1.ApplicationSourceTypeKustomize, sourceType)

	sourceType, err = GetAppSourceType(t.Context(), &v1alpha1.ApplicationSource{}, "./testdata/Kustomization", "./testdata", "testapp", map[string]bool{}, []string{}, []string{})
	require.NoError(t, err)
	assert.Equal(t, v1alpha1.ApplicationSourceTypeKustomize, sourceType)
}

func TestGenerateFromUTF16(t *testing.T) {
	q := apiclient.ManifestRequest{
		Repo:               &v1alpha1.Repository{},
		ApplicationSource:  &v1alpha1.ApplicationSource{},
		ProjectName:        "something",
		ProjectSourceRepos: []string{"*"},
	}
	res1, err := GenerateManifests(t.Context(), "./testdata/utf-16", "/", "", &q, false, &git.NoopCredsStore{}, resource.MustParse("0"), nil)
	require.NoError(t, err)
	assert.Len(t, res1.Manifests, 2)
}

func TestListApps(t *testing.T) {
	service := newService(t, "./testdata")

	res, err := service.ListApps(t.Context(), &apiclient.ListAppsRequest{Repo: &v1alpha1.Repository{}})
	require.NoError(t, err)

	expectedApps := map[string]string{
		"Kustomization":                     "Kustomize",
		"app-parameters/multi":              "Kustomize",
		"app-parameters/single-app-only":    "Kustomize",
		"app-parameters/single-global":      "Kustomize",
		"app-parameters/single-global-helm": "Helm",
		"in-bounds-values-file-link":        "Helm",
		"invalid-helm":                      "Helm",
		"invalid-kustomize":                 "Kustomize",
		"kustomization_yaml":                "Kustomize",
		"kustomization_yml":                 "Kustomize",
		"my-chart":                          "Helm",
		"my-chart-2":                        "Helm",
		"oci-dependencies":                  "Helm",
		"out-of-bounds-values-file-link":    "Helm",
		"values-files":                      "Helm",
		"helm-with-dependencies":            "Helm",
		"helm-with-dependencies-alias":      "Helm",
		"helm-with-local-dependency":        "Helm",
		"simple-chart":                      "Helm",
		"broken-schema-verification":        "Helm",
	}
	assert.Equal(t, expectedApps, res.Apps)
}

func TestGetAppDetailsHelm(t *testing.T) {
	service := newService(t, "../../util/helm/testdata/dependency")

	res, err := service.GetAppDetails(t.Context(), &apiclient.RepoServerAppDetailsQuery{
		Repo: &v1alpha1.Repository{},
		Source: &v1alpha1.ApplicationSource{
			Path: ".",
		},
	})

	require.NoError(t, err)
	assert.NotNil(t, res.Helm)

	assert.Equal(t, "Helm", res.Type)
	assert.Equal(t, []string{"values-production.yaml", "values.yaml"}, res.Helm.ValueFiles)
}

func TestGetAppDetailsHelmUsesCache(t *testing.T) {
	service := newService(t, "../../util/helm/testdata/dependency")

	res, err := service.GetAppDetails(t.Context(), &apiclient.RepoServerAppDetailsQuery{
		Repo: &v1alpha1.Repository{},
		Source: &v1alpha1.ApplicationSource{
			Path: ".",
		},
	})

	require.NoError(t, err)
	assert.NotNil(t, res.Helm)

	assert.Equal(t, "Helm", res.Type)
	assert.Equal(t, []string{"values-production.yaml", "values.yaml"}, res.Helm.ValueFiles)
}

func TestGetAppDetailsHelm_WithNoValuesFile(t *testing.T) {
	service := newService(t, "../../util/helm/testdata/api-versions")

	res, err := service.GetAppDetails(t.Context(), &apiclient.RepoServerAppDetailsQuery{
		Repo: &v1alpha1.Repository{},
		Source: &v1alpha1.ApplicationSource{
			Path: ".",
		},
	})

	require.NoError(t, err)
	assert.NotNil(t, res.Helm)

	assert.Equal(t, "Helm", res.Type)
	assert.Empty(t, res.Helm.ValueFiles)
	assert.Empty(t, res.Helm.Values)
}

func TestGetAppDetailsKustomize(t *testing.T) {
	service := newService(t, "../../util/kustomize/testdata/kustomization_yaml")

	res, err := service.GetAppDetails(t.Context(), &apiclient.RepoServerAppDetailsQuery{
		Repo: &v1alpha1.Repository{},
		Source: &v1alpha1.ApplicationSource{
			Path: ".",
		},
	})

	require.NoError(t, err)

	assert.Equal(t, "Kustomize", res.Type)
	assert.NotNil(t, res.Kustomize)
	assert.Equal(t, []string{"nginx:1.15.4", "registry.k8s.io/nginx-slim:0.8"}, res.Kustomize.Images)
}

func TestGetAppDetailsKustomize_CustomVersion(t *testing.T) {
	service := newService(t, "../../util/kustomize/testdata/kustomize-with-version-override")

	q := &apiclient.RepoServerAppDetailsQuery{
		Repo: &v1alpha1.Repository{},
		Source: &v1alpha1.ApplicationSource{
			Path: ".",
		},
		KustomizeOptions: &v1alpha1.KustomizeOptions{},
	}

	_, err := service.GetAppDetails(t.Context(), q)
	require.ErrorAs(t, err, &settings.KustomizeVersionNotRegisteredError{Version: "v1.2.3"})

	q.KustomizeOptions.Versions = []v1alpha1.KustomizeVersion{
		{
			Name: "v1.2.3",
			Path: "kustomize",
		},
	}

	res, err := service.GetAppDetails(t.Context(), q)
	require.NoError(t, err)
	assert.Equal(t, "Kustomize", res.Type)
}

func TestGetHelmCharts(t *testing.T) {
	service := newService(t, "../..")
	res, err := service.GetHelmCharts(t.Context(), &apiclient.HelmChartsRequest{Repo: &v1alpha1.Repository{}})

	// fix flakiness
	sort.Slice(res.Items, func(i, j int) bool {
		return res.Items[i].Name < res.Items[j].Name
	})

	require.NoError(t, err)
	assert.Len(t, res.Items, 2)

	item := res.Items[0]
	assert.Equal(t, "my-chart", item.Name)
	assert.Equal(t, []string{"1.0.0", "1.1.0"}, item.Versions)

	item2 := res.Items[1]
	assert.Equal(t, "out-of-bounds-chart", item2.Name)
	assert.Equal(t, []string{"1.0.0", "1.1.0"}, item2.Versions)
}

func TestGetRevisionMetadata(t *testing.T) {
	service, gitClient, _ := newServiceWithMocks(t, "../..", false)
	now := time.Now()

	gitClient.EXPECT().RevisionMetadata(mock.Anything).Return(&git.RevisionMetadata{
		Message: "test",
		Author:  "author",
		Date:    now,
		Tags:    []string{"tag1", "tag2"},
		References: []git.RevisionReference{
			{
				Commit: &git.CommitMetadata{
					Author: mail.Address{
						Name:    "test-name",
						Address: "test-email@example.com",
					},
					Date:    now.Format(time.RFC3339),
					Subject: "test-subject",
					SHA:     "test-sha",
					RepoURL: "test-repo-url",
				},
			},
		},
	}, nil)

	res, err := service.GetRevisionMetadata(t.Context(), &apiclient.RepoServerRevisionMetadataRequest{
		Repo:           &v1alpha1.Repository{},
		Revision:       "c0b400fc458875d925171398f9ba9eabd5529923",
		CheckSignature: true,
	})

	require.NoError(t, err)
	assert.Equal(t, "test", res.Message)
	assert.Equal(t, now, res.Date.Time)
	assert.Equal(t, "author", res.Author)
	assert.Equal(t, []string{"tag1", "tag2"}, res.Tags)
	assert.NotEmpty(t, res.SignatureInfo)
	require.Len(t, res.References, 1)
	require.NotNil(t, res.References[0].Commit)
	assert.Equal(t, "test-sha", res.References[0].Commit.SHA)

	// Check for truncated revision value
	res, err = service.GetRevisionMetadata(t.Context(), &apiclient.RepoServerRevisionMetadataRequest{
		Repo:           &v1alpha1.Repository{},
		Revision:       "c0b400f",
		CheckSignature: true,
	})

	require.NoError(t, err)
	assert.Equal(t, "test", res.Message)
	assert.Equal(t, now, res.Date.Time)
	assert.Equal(t, "author", res.Author)
	assert.Equal(t, []string{"tag1", "tag2"}, res.Tags)
	assert.NotEmpty(t, res.SignatureInfo)

	// Cache hit - signature info should not be in result
	res, err = service.GetRevisionMetadata(t.Context(), &apiclient.RepoServerRevisionMetadataRequest{
		Repo:           &v1alpha1.Repository{},
		Revision:       "c0b400fc458875d925171398f9ba9eabd5529923",
		CheckSignature: false,
	})
	require.NoError(t, err)
	assert.Empty(t, res.SignatureInfo)

	// Enforce cache miss - signature info should not be in result
	res, err = service.GetRevisionMetadata(t.Context(), &apiclient.RepoServerRevisionMetadataRequest{
		Repo:           &v1alpha1.Repository{},
		Revision:       "da52afd3b2df1ec49470603d8bbb46954dab1091",
		CheckSignature: false,
	})
	require.NoError(t, err)
	assert.Empty(t, res.SignatureInfo)

	// Cache hit on previous entry that did not have signature info
	res, err = service.GetRevisionMetadata(t.Context(), &apiclient.RepoServerRevisionMetadataRequest{
		Repo:           &v1alpha1.Repository{},
		Revision:       "da52afd3b2df1ec49470603d8bbb46954dab1091",
		CheckSignature: true,
	})
	require.NoError(t, err)
	assert.NotEmpty(t, res.SignatureInfo)
}

func TestGetSignatureVerificationResult(t *testing.T) {
	// Commit with signature and verification requested
	{
		service := newServiceWithSignature(t, "../../manifests/base")

		src := v1alpha1.ApplicationSource{Path: "."}
		q := apiclient.ManifestRequest{
			Repo:               &v1alpha1.Repository{},
			ApplicationSource:  &src,
			VerifySignature:    true,
			ProjectName:        "something",
			ProjectSourceRepos: []string{"*"},
		}

		res, err := service.GenerateManifest(t.Context(), &q)
		require.NoError(t, err)
		assert.Equal(t, testSignature, res.VerifyResult)
	}
	// Commit with signature and verification not requested
	{
		service := newServiceWithSignature(t, "../../manifests/base")

		src := v1alpha1.ApplicationSource{Path: "."}
		q := apiclient.ManifestRequest{
			Repo: &v1alpha1.Repository{}, ApplicationSource: &src, ProjectName: "something",
			ProjectSourceRepos: []string{"*"},
		}

		res, err := service.GenerateManifest(t.Context(), &q)
		require.NoError(t, err)
		assert.Empty(t, res.VerifyResult)
	}
	// Commit without signature and verification requested
	{
		service := newService(t, "../../manifests/base")

		src := v1alpha1.ApplicationSource{Path: "."}
		q := apiclient.ManifestRequest{
			Repo: &v1alpha1.Repository{}, ApplicationSource: &src, VerifySignature: true, ProjectName: "something",
			ProjectSourceRepos: []string{"*"},
		}

		res, err := service.GenerateManifest(t.Context(), &q)
		require.NoError(t, err)
		assert.Empty(t, res.VerifyResult)
	}
	// Commit without signature and verification not requested
	{
		service := newService(t, "../../manifests/base")

		src := v1alpha1.ApplicationSource{Path: "."}
		q := apiclient.ManifestRequest{
			Repo: &v1alpha1.Repository{}, ApplicationSource: &src, VerifySignature: true, ProjectName: "something",
			ProjectSourceRepos: []string{"*"},
		}

		res, err := service.GenerateManifest(t.Context(), &q)
		require.NoError(t, err)
		assert.Empty(t, res.VerifyResult)
	}
}

func Test_newEnv(t *testing.T) {
	assert.Equal(t, &v1alpha1.Env{
		&v1alpha1.EnvEntry{Name: "ARGOCD_APP_NAME", Value: "my-app-name"},
		&v1alpha1.EnvEntry{Name: "ARGOCD_APP_NAMESPACE", Value: "my-namespace"},
		&v1alpha1.EnvEntry{Name: "ARGOCD_APP_PROJECT_NAME", Value: "my-project-name"},
		&v1alpha1.EnvEntry{Name: "ARGOCD_APP_REVISION", Value: "my-revision"},
		&v1alpha1.EnvEntry{Name: "ARGOCD_APP_REVISION_SHORT", Value: "my-revi"},
		&v1alpha1.EnvEntry{Name: "ARGOCD_APP_REVISION_SHORT_8", Value: "my-revis"},
		&v1alpha1.EnvEntry{Name: "ARGOCD_APP_SOURCE_REPO_URL", Value: "https://github.com/my-org/my-repo"},
		&v1alpha1.EnvEntry{Name: "ARGOCD_APP_SOURCE_PATH", Value: "my-path"},
		&v1alpha1.EnvEntry{Name: "ARGOCD_APP_SOURCE_TARGET_REVISION", Value: "my-target-revision"},
	}, newEnv(&apiclient.ManifestRequest{
		AppName:     "my-app-name",
		Namespace:   "my-namespace",
		ProjectName: "my-project-name",
		Repo:        &v1alpha1.Repository{Repo: "https://github.com/my-org/my-repo"},
		ApplicationSource: &v1alpha1.ApplicationSource{
			Path:           "my-path",
			TargetRevision: "my-target-revision",
		},
	}, "my-revision"))
}

func TestService_newHelmClientResolveRevision(t *testing.T) {
	service := newService(t, ".")

	t.Run("EmptyRevision", func(t *testing.T) {
		_, _, err := service.newHelmClientResolveRevision(&v1alpha1.Repository{}, "", "my-chart", true)
		assert.EqualError(t, err, "invalid revision: failed to determine semver constraint: improper constraint: ")
	})
	t.Run("InvalidRevision", func(t *testing.T) {
		_, _, err := service.newHelmClientResolveRevision(&v1alpha1.Repository{}, "???", "my-chart", true)
		assert.EqualError(t, err, "invalid revision: failed to determine semver constraint: improper constraint: ???")
	})
}

func TestGetAppDetailsWithAppParameterFile(t *testing.T) {
	t.Run("No app name set and app specific file exists", func(t *testing.T) {
		service := newService(t, ".")
		runWithTempTestdata(t, "multi", func(t *testing.T, path string) {
			t.Helper()
			details, err := service.GetAppDetails(t.Context(), &apiclient.RepoServerAppDetailsQuery{
				Repo: &v1alpha1.Repository{},
				Source: &v1alpha1.ApplicationSource{
					Path: path,
				},
			})
			require.NoError(t, err)
			assert.Equal(t, []string{"quay.io/argoprojlabs/argocd-e2e-container:0.2"}, details.Kustomize.Images)
		})
	})
	t.Run("No app specific override", func(t *testing.T) {
		service := newService(t, ".")
		runWithTempTestdata(t, "single-global", func(t *testing.T, path string) {
			t.Helper()
			details, err := service.GetAppDetails(t.Context(), &apiclient.RepoServerAppDetailsQuery{
				Repo: &v1alpha1.Repository{},
				Source: &v1alpha1.ApplicationSource{
					Path: path,
				},
				AppName: "testapp",
			})
			require.NoError(t, err)
			assert.Equal(t, []string{"quay.io/argoprojlabs/argocd-e2e-container:0.2"}, details.Kustomize.Images)
		})
	})
	t.Run("Only app specific override", func(t *testing.T) {
		service := newService(t, ".")
		runWithTempTestdata(t, "single-app-only", func(t *testing.T, path string) {
			t.Helper()
			details, err := service.GetAppDetails(t.Context(), &apiclient.RepoServerAppDetailsQuery{
				Repo: &v1alpha1.Repository{},
				Source: &v1alpha1.ApplicationSource{
					Path: path,
				},
				AppName: "testapp",
			})
			require.NoError(t, err)
			assert.Equal(t, []string{"quay.io/argoprojlabs/argocd-e2e-container:0.3"}, details.Kustomize.Images)
		})
	})
	t.Run("App specific override", func(t *testing.T) {
		service := newService(t, ".")
		runWithTempTestdata(t, "multi", func(t *testing.T, path string) {
			t.Helper()
			details, err := service.GetAppDetails(t.Context(), &apiclient.RepoServerAppDetailsQuery{
				Repo: &v1alpha1.Repository{},
				Source: &v1alpha1.ApplicationSource{
					Path: path,
				},
				AppName: "testapp",
			})
			require.NoError(t, err)
			assert.Equal(t, []string{"quay.io/argoprojlabs/argocd-e2e-container:0.3"}, details.Kustomize.Images)
		})
	})
	t.Run("App specific overrides containing non-mergeable field", func(t *testing.T) {
		service := newService(t, ".")
		runWithTempTestdata(t, "multi", func(t *testing.T, path string) {
			t.Helper()
			details, err := service.GetAppDetails(t.Context(), &apiclient.RepoServerAppDetailsQuery{
				Repo: &v1alpha1.Repository{},
				Source: &v1alpha1.ApplicationSource{
					Path: path,
				},
				AppName: "unmergeable",
			})
			require.NoError(t, err)
			assert.Equal(t, []string{"quay.io/argoprojlabs/argocd-e2e-container:0.3"}, details.Kustomize.Images)
		})
	})
	t.Run("Broken app-specific overrides", func(t *testing.T) {
		service := newService(t, ".")
		runWithTempTestdata(t, "multi", func(t *testing.T, path string) {
			t.Helper()
			_, err := service.GetAppDetails(t.Context(), &apiclient.RepoServerAppDetailsQuery{
				Repo: &v1alpha1.Repository{},
				Source: &v1alpha1.ApplicationSource{
					Path: path,
				},
				AppName: "broken",
			})
			require.Error(t, err)
		})
	})
}

// There are unit test that will use kustomize set and by that modify the
// kustomization.yaml. For proper testing, we need to copy the testdata to a
// temporary path, run the tests, and then throw the copy away again.
func mkTempParameters(ctx context.Context, source string) string {
	tempDir, err := os.MkdirTemp("./testdata", "app-parameters")
	if err != nil {
		panic(err)
	}
	cmd := exec.CommandContext(ctx, "cp", "-R", source, tempDir)
	err = cmd.Run()
	if err != nil {
		os.RemoveAll(tempDir)
		panic(err)
	}
	return tempDir
}

// Simple wrapper run a test with a temporary copy of the testdata, because
// the test would modify the data when run.
func runWithTempTestdata(t *testing.T, path string, runner func(t *testing.T, path string)) {
	t.Helper()
	tempDir := mkTempParameters(t.Context(), "./testdata/app-parameters")
	runner(t, filepath.Join(tempDir, "app-parameters", path))
	os.RemoveAll(tempDir)
}

func TestGenerateManifestsWithAppParameterFile(t *testing.T) {
	t.Run("Single global override", func(t *testing.T) {
		runWithTempTestdata(t, "single-global", func(t *testing.T, path string) {
			t.Helper()
			service := newService(t, ".")
			manifests, err := service.GenerateManifest(t.Context(), &apiclient.ManifestRequest{
				Repo: &v1alpha1.Repository{},
				ApplicationSource: &v1alpha1.ApplicationSource{
					Path: path,
				},
				ProjectName:        "something",
				ProjectSourceRepos: []string{"*"},
			})
			require.NoError(t, err)
			resourceByKindName := make(map[string]*unstructured.Unstructured)
			for _, manifest := range manifests.Manifests {
				var un unstructured.Unstructured
				err := yaml.Unmarshal([]byte(manifest), &un)
				require.NoError(t, err)
				resourceByKindName[fmt.Sprintf("%s/%s", un.GetKind(), un.GetName())] = &un
			}
			deployment, ok := resourceByKindName["Deployment/guestbook-ui"]
			require.True(t, ok)
			containers, ok, _ := unstructured.NestedSlice(deployment.Object, "spec", "template", "spec", "containers")
			require.True(t, ok)
			image, ok, _ := unstructured.NestedString(containers[0].(map[string]any), "image")
			require.True(t, ok)
			assert.Equal(t, "quay.io/argoprojlabs/argocd-e2e-container:0.2", image)
		})
	})

	t.Run("Single global override Helm", func(t *testing.T) {
		runWithTempTestdata(t, "single-global-helm", func(t *testing.T, path string) {
			t.Helper()
			service := newService(t, ".")
			manifests, err := service.GenerateManifest(t.Context(), &apiclient.ManifestRequest{
				Repo: &v1alpha1.Repository{},
				ApplicationSource: &v1alpha1.ApplicationSource{
					Path: path,
				},
				ProjectName:        "something",
				ProjectSourceRepos: []string{"*"},
			})
			require.NoError(t, err)
			resourceByKindName := make(map[string]*unstructured.Unstructured)
			for _, manifest := range manifests.Manifests {
				var un unstructured.Unstructured
				err := yaml.Unmarshal([]byte(manifest), &un)
				require.NoError(t, err)
				resourceByKindName[fmt.Sprintf("%s/%s", un.GetKind(), un.GetName())] = &un
			}
			deployment, ok := resourceByKindName["Deployment/guestbook-ui"]
			require.True(t, ok)
			containers, ok, _ := unstructured.NestedSlice(deployment.Object, "spec", "template", "spec", "containers")
			require.True(t, ok)
			image, ok, _ := unstructured.NestedString(containers[0].(map[string]any), "image")
			require.True(t, ok)
			assert.Equal(t, "quay.io/argoprojlabs/argocd-e2e-container:0.2", image)
		})
	})

	t.Run("Application specific override", func(t *testing.T) {
		service := newService(t, ".")
		runWithTempTestdata(t, "single-app-only", func(t *testing.T, path string) {
			t.Helper()
			manifests, err := service.GenerateManifest(t.Context(), &apiclient.ManifestRequest{
				Repo: &v1alpha1.Repository{},
				ApplicationSource: &v1alpha1.ApplicationSource{
					Path: path,
				},
				AppName:            "testapp",
				ProjectName:        "something",
				ProjectSourceRepos: []string{"*"},
			})
			require.NoError(t, err)
			resourceByKindName := make(map[string]*unstructured.Unstructured)
			for _, manifest := range manifests.Manifests {
				var un unstructured.Unstructured
				err := yaml.Unmarshal([]byte(manifest), &un)
				require.NoError(t, err)
				resourceByKindName[fmt.Sprintf("%s/%s", un.GetKind(), un.GetName())] = &un
			}
			deployment, ok := resourceByKindName["Deployment/guestbook-ui"]
			require.True(t, ok)
			containers, ok, _ := unstructured.NestedSlice(deployment.Object, "spec", "template", "spec", "containers")
			require.True(t, ok)
			image, ok, _ := unstructured.NestedString(containers[0].(map[string]any), "image")
			require.True(t, ok)
			assert.Equal(t, "quay.io/argoprojlabs/argocd-e2e-container:0.3", image)
		})
	})

	t.Run("Multi-source with source as ref only does not generate manifests", func(t *testing.T) {
		service := newService(t, ".")
		runWithTempTestdata(t, "single-app-only", func(t *testing.T, _ string) {
			t.Helper()
			manifests, err := service.GenerateManifest(t.Context(), &apiclient.ManifestRequest{
				Repo: &v1alpha1.Repository{},
				ApplicationSource: &v1alpha1.ApplicationSource{
					Path:  "",
					Chart: "",
					Ref:   "test",
				},
				AppName:            "testapp-multi-ref-only",
				ProjectName:        "something",
				ProjectSourceRepos: []string{"*"},
				HasMultipleSources: true,
			})
			require.NoError(t, err)
			assert.Empty(t, manifests.Manifests)
			assert.NotEmpty(t, manifests.Revision)
		})
	})

	t.Run("Application specific override for other app", func(t *testing.T) {
		service := newService(t, ".")
		runWithTempTestdata(t, "single-app-only", func(t *testing.T, path string) {
			t.Helper()
			manifests, err := service.GenerateManifest(t.Context(), &apiclient.ManifestRequest{
				Repo: &v1alpha1.Repository{},
				ApplicationSource: &v1alpha1.ApplicationSource{
					Path: path,
				},
				AppName:            "testapp2",
				ProjectName:        "something",
				ProjectSourceRepos: []string{"*"},
			})
			require.NoError(t, err)
			resourceByKindName := make(map[string]*unstructured.Unstructured)
			for _, manifest := range manifests.Manifests {
				var un unstructured.Unstructured
				err := yaml.Unmarshal([]byte(manifest), &un)
				require.NoError(t, err)
				resourceByKindName[fmt.Sprintf("%s/%s", un.GetKind(), un.GetName())] = &un
			}
			deployment, ok := resourceByKindName["Deployment/guestbook-ui"]
			require.True(t, ok)
			containers, ok, _ := unstructured.NestedSlice(deployment.Object, "spec", "template", "spec", "containers")
			require.True(t, ok)
			image, ok, _ := unstructured.NestedString(containers[0].(map[string]any), "image")
			require.True(t, ok)
			assert.Equal(t, "quay.io/argoprojlabs/argocd-e2e-container:0.1", image)
		})
	})

	t.Run("Override info does not appear in cache key", func(t *testing.T) {
		service := newService(t, ".")
		runWithTempTestdata(t, "single-global", func(t *testing.T, path string) {
			t.Helper()
			source := &v1alpha1.ApplicationSource{
				Path: path,
			}
			sourceCopy := source.DeepCopy() // make a copy in case GenerateManifest mutates it.
			_, err := service.GenerateManifest(t.Context(), &apiclient.ManifestRequest{
				Repo:               &v1alpha1.Repository{},
				ApplicationSource:  sourceCopy,
				AppName:            "test",
				ProjectName:        "something",
				ProjectSourceRepos: []string{"*"},
			})
			require.NoError(t, err)
			res := &cache.CachedManifestResponse{}
			// Try to pull from the cache with a `source` that does not include any overrides. Overrides should not be
			// part of the cache key, because you can't get the overrides without a repo operation. And avoiding repo
			// operations is the point of the cache.
			err = service.cache.GetManifests(mock.Anything, source, v1alpha1.RefTargetRevisionMapping{}, &v1alpha1.ClusterInfo{}, "", "", "", "test", res, nil, "")
			require.NoError(t, err)
		})
	})
}

func TestGenerateManifestWithAnnotatedAndRegularGitTagHashes(t *testing.T) {
	regularGitTagHash := "632039659e542ed7de0c170a4fcc1c571b288fc0"
	annotatedGitTaghash := "95249be61b028d566c29d47b19e65c5603388a41"
	invalidGitTaghash := "invalid-tag"
	actualCommitSHA := "632039659e542ed7de0c170a4fcc1c571b288fc0"

	tests := []struct {
		name            string
		ctx             context.Context
		manifestRequest *apiclient.ManifestRequest
		wantError       bool
		service         *Service
	}{
		{
			name: "Case: Git tag hash matches latest commit SHA (regular tag)",
			ctx:  t.Context(),
			manifestRequest: &apiclient.ManifestRequest{
				Repo: &v1alpha1.Repository{},
				ApplicationSource: &v1alpha1.ApplicationSource{
					TargetRevision: regularGitTagHash,
				},
				NoCache:            true,
				ProjectName:        "something",
				ProjectSourceRepos: []string{"*"},
			},
			wantError: false,
			service:   newServiceWithCommitSHA(t, ".", regularGitTagHash),
		},

		{
			name: "Case: Git tag hash does not match latest commit SHA (annotated tag)",
			ctx:  t.Context(),
			manifestRequest: &apiclient.ManifestRequest{
				Repo: &v1alpha1.Repository{},
				ApplicationSource: &v1alpha1.ApplicationSource{
					TargetRevision: annotatedGitTaghash,
				},
				NoCache:            true,
				ProjectName:        "something",
				ProjectSourceRepos: []string{"*"},
			},
			wantError: false,
			service:   newServiceWithCommitSHA(t, ".", annotatedGitTaghash),
		},

		{
			name: "Case: Git tag hash is invalid",
			ctx:  t.Context(),
			manifestRequest: &apiclient.ManifestRequest{
				Repo: &v1alpha1.Repository{},
				ApplicationSource: &v1alpha1.ApplicationSource{
					TargetRevision: invalidGitTaghash,
				},
				NoCache:            true,
				ProjectName:        "something",
				ProjectSourceRepos: []string{"*"},
			},
			wantError: true,
			service:   newServiceWithCommitSHA(t, ".", invalidGitTaghash),
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			manifestResponse, err := tt.service.GenerateManifest(tt.ctx, tt.manifestRequest)
			if !tt.wantError {
				require.NoError(t, err)
				assert.Equal(t, manifestResponse.Revision, actualCommitSHA)
			} else {
				assert.Errorf(t, err, "expected an error but did not throw one")
			}
		})
	}
}

func TestGenerateManifestWithAnnotatedTagsAndMultiSourceApp(t *testing.T) {
	annotatedGitTaghash := "95249be61b028d566c29d47b19e65c5603388a41"

	service := newServiceWithCommitSHA(t, ".", annotatedGitTaghash)

	refSources := map[string]*v1alpha1.RefTarget{}

	refSources["$global"] = &v1alpha1.RefTarget{
		TargetRevision: annotatedGitTaghash,
	}

	refSources["$default"] = &v1alpha1.RefTarget{
		TargetRevision: annotatedGitTaghash,
	}

	manifestRequest := &apiclient.ManifestRequest{
		Repo: &v1alpha1.Repository{},
		ApplicationSource: &v1alpha1.ApplicationSource{
			TargetRevision: annotatedGitTaghash,
			Helm: &v1alpha1.ApplicationSourceHelm{
				ValueFiles: []string{"$global/values.yaml", "$default/secrets.yaml"},
			},
		},
		HasMultipleSources: true,
		NoCache:            true,
		RefSources:         refSources,
	}

	response, err := service.GenerateManifest(t.Context(), manifestRequest)
	require.NoError(t, err)
	assert.Equalf(t, response.Revision, annotatedGitTaghash, "returned SHA %s is different from expected annotated tag %s", response.Revision, annotatedGitTaghash)
}

func TestGenerateMultiSourceHelmWithFileParameter(t *testing.T) {
	expectedFileContent, err := os.ReadFile("../../util/helm/testdata/external/external-secret.txt")
	require.NoError(t, err)

	service := newService(t, "../../util/helm/testdata")

	testCases := []struct {
		name            string
		refSources      map[string]*v1alpha1.RefTarget
		expectedContent string
		expectedErr     bool
	}{{
		name: "Successfully resolve multi-source ref for helm set-file",
		refSources: map[string]*v1alpha1.RefTarget{
			"$global": {
				TargetRevision: "HEAD",
			},
		},
		expectedContent: string(expectedFileContent),
		expectedErr:     false,
	}, {
		name:            "Failed to resolve multi-source ref for helm set-file",
		refSources:      map[string]*v1alpha1.RefTarget{},
		expectedContent: "DOES-NOT-EXIST",
		expectedErr:     true,
	}}

	for i := range testCases {
		tc := testCases[i]
		t.Run(tc.name, func(t *testing.T) {
			manifestRequest := &apiclient.ManifestRequest{
				Repo: &v1alpha1.Repository{},
				ApplicationSource: &v1alpha1.ApplicationSource{
					Ref:            "$global",
					Path:           "./redis",
					TargetRevision: "HEAD",
					Helm: &v1alpha1.ApplicationSourceHelm{
						ValueFiles: []string{"$global/redis/values-production.yaml"},
						FileParameters: []v1alpha1.HelmFileParameter{{
							Name: "passwordContent",
							Path: "$global/external/external-secret.txt",
						}},
					},
				},
				HasMultipleSources: true,
				NoCache:            true,
				RefSources:         tc.refSources,
			}

			res, err := service.GenerateManifest(t.Context(), manifestRequest)

			if !tc.expectedErr {
				require.NoError(t, err)

				// Check that any of the manifests contains the secret
				idx := slices.IndexFunc(res.Manifests, func(content string) bool {
					return strings.Contains(content, tc.expectedContent)
				})
				assert.GreaterOrEqual(t, idx, 0, "No manifest contains the value set with the helm fileParameters")
			} else {
				assert.Error(t, err)
			}
		})
	}
}

func TestFindResources(t *testing.T) {
	testCases := []struct {
		name          string
		include       string
		exclude       string
		expectedNames []string
	}{{
		name:          "Include One Match",
		include:       "subdir/deploymentSub.yaml",
		expectedNames: []string{"nginx-deployment-sub"},
	}, {
		name:          "Include Everything",
		include:       "*.yaml",
		expectedNames: []string{"nginx-deployment", "nginx-deployment-sub"},
	}, {
		name:          "Include Subdirectory",
		include:       "**/*.yaml",
		expectedNames: []string{"nginx-deployment-sub"},
	}, {
		name:          "Include No Matches",
		include:       "nothing.yaml",
		expectedNames: []string{},
	}, {
		name:          "Exclude - One Match",
		exclude:       "subdir/deploymentSub.yaml",
		expectedNames: []string{"nginx-deployment"},
	}, {
		name:          "Exclude - Everything",
		exclude:       "*.yaml",
		expectedNames: []string{},
	}}
	for i := range testCases {
		tc := testCases[i]
		t.Run(tc.name, func(t *testing.T) {
			objs, err := findManifests(&log.Entry{}, "testdata/app-include-exclude", ".", nil, v1alpha1.ApplicationSourceDirectory{
				Recurse: true,
				Include: tc.include,
				Exclude: tc.exclude,
			}, map[string]bool{}, resource.MustParse("0"))
			require.NoError(t, err)
			var names []string
			for i := range objs {
				names = append(names, objs[i].GetName())
			}
			assert.ElementsMatch(t, tc.expectedNames, names)
		})
	}
}

func TestFindManifests_Exclude(t *testing.T) {
	objs, err := findManifests(&log.Entry{}, "testdata/app-include-exclude", ".", nil, v1alpha1.ApplicationSourceDirectory{
		Recurse: true,
		Exclude: "subdir/deploymentSub.yaml",
	}, map[string]bool{}, resource.MustParse("0"))

	require.NoError(t, err)
	require.Len(t, objs, 1)

	assert.Equal(t, "nginx-deployment", objs[0].GetName())
}

func TestFindManifests_Exclude_NothingMatches(t *testing.T) {
	objs, err := findManifests(&log.Entry{}, "testdata/app-include-exclude", ".", nil, v1alpha1.ApplicationSourceDirectory{
		Recurse: true,
		Exclude: "nothing.yaml",
	}, map[string]bool{}, resource.MustParse("0"))

	require.NoError(t, err)
	require.Len(t, objs, 2)

	assert.ElementsMatch(t,
		[]string{"nginx-deployment", "nginx-deployment-sub"}, []string{objs[0].GetName(), objs[1].GetName()})
}

func tempDir(t *testing.T) string {
	t.Helper()
	dir, err := os.MkdirTemp(".", "")
	require.NoError(t, err)
	t.Cleanup(func() {
		err = os.RemoveAll(dir)
		if err != nil {
			panic(err)
		}
	})
	absDir, err := filepath.Abs(dir)
	require.NoError(t, err)
	return absDir
}

func walkFor(t *testing.T, root string, testPath string, run func(info fs.FileInfo)) {
	t.Helper()
	hitExpectedPath := false
	err := filepath.Walk(root, func(path string, info fs.FileInfo, err error) error {
		if path == testPath {
			require.NoError(t, err)
			hitExpectedPath = true
			run(info)
		}
		return nil
	})
	require.NoError(t, err)
	assert.True(t, hitExpectedPath, "did not hit expected path when walking directory")
}

func Test_getPotentiallyValidManifestFile(t *testing.T) {
	// These tests use filepath.Walk instead of os.Stat to get file info, because FileInfo from os.Stat does not return
	// true for IsSymlink like os.Walk does.

	// These tests do not use t.TempDir() because those directories can contain symlinks which cause test to fail
	// InBound checks.

	t.Run("non-JSON/YAML is skipped with an empty ignore message", func(t *testing.T) {
		appDir := tempDir(t)
		filePath := filepath.Join(appDir, "not-json-or-yaml")
		file, err := os.OpenFile(filePath, os.O_RDONLY|os.O_CREATE, 0o644)
		require.NoError(t, err)
		err = file.Close()
		require.NoError(t, err)

		walkFor(t, appDir, filePath, func(info fs.FileInfo) {
			realFileInfo, ignoreMessage, err := getPotentiallyValidManifestFile(filePath, info, appDir, appDir, "", "")
			assert.Nil(t, realFileInfo)
			assert.Empty(t, ignoreMessage)
			require.NoError(t, err)
		})
	})

	t.Run("circular link should throw an error", func(t *testing.T) {
		appDir := tempDir(t)

		aPath := filepath.Join(appDir, "a.json")
		bPath := filepath.Join(appDir, "b.json")
		err := os.Symlink(bPath, aPath)
		require.NoError(t, err)
		err = os.Symlink(aPath, bPath)
		require.NoError(t, err)

		walkFor(t, appDir, aPath, func(info fs.FileInfo) {
			realFileInfo, ignoreMessage, err := getPotentiallyValidManifestFile(aPath, info, appDir, appDir, "", "")
			assert.Nil(t, realFileInfo)
			assert.Empty(t, ignoreMessage)
			assert.ErrorContains(t, err, "too many links")
		})
	})

	t.Run("symlink with missing destination should throw an error", func(t *testing.T) {
		appDir := tempDir(t)

		aPath := filepath.Join(appDir, "a.json")
		bPath := filepath.Join(appDir, "b.json")
		err := os.Symlink(bPath, aPath)
		require.NoError(t, err)

		walkFor(t, appDir, aPath, func(info fs.FileInfo) {
			realFileInfo, ignoreMessage, err := getPotentiallyValidManifestFile(aPath, info, appDir, appDir, "", "")
			assert.Nil(t, realFileInfo)
			assert.NotEmpty(t, ignoreMessage)
			require.NoError(t, err)
		})
	})

	t.Run("out-of-bounds symlink should throw an error", func(t *testing.T) {
		appDir := tempDir(t)

		linkPath := filepath.Join(appDir, "a.json")
		err := os.Symlink("..", linkPath)
		require.NoError(t, err)

		walkFor(t, appDir, linkPath, func(info fs.FileInfo) {
			realFileInfo, ignoreMessage, err := getPotentiallyValidManifestFile(linkPath, info, appDir, appDir, "", "")
			assert.Nil(t, realFileInfo)
			assert.Empty(t, ignoreMessage)
			assert.ErrorContains(t, err, "illegal filepath in symlink")
		})
	})

	t.Run("symlink to a non-regular file should be skipped with warning", func(t *testing.T) {
		appDir := tempDir(t)

		dirPath := filepath.Join(appDir, "test.dir")
		err := os.MkdirAll(dirPath, 0o644)
		require.NoError(t, err)
		linkPath := filepath.Join(appDir, "test.json")
		err = os.Symlink(dirPath, linkPath)
		require.NoError(t, err)

		walkFor(t, appDir, linkPath, func(info fs.FileInfo) {
			realFileInfo, ignoreMessage, err := getPotentiallyValidManifestFile(linkPath, info, appDir, appDir, "", "")
			assert.Nil(t, realFileInfo)
			assert.Contains(t, ignoreMessage, "non-regular file")
			require.NoError(t, err)
		})
	})

	t.Run("non-included file should be skipped with no message", func(t *testing.T) {
		appDir := tempDir(t)

		filePath := filepath.Join(appDir, "not-included.yaml")
		file, err := os.OpenFile(filePath, os.O_RDONLY|os.O_CREATE, 0o644)
		require.NoError(t, err)
		err = file.Close()
		require.NoError(t, err)

		walkFor(t, appDir, filePath, func(info fs.FileInfo) {
			realFileInfo, ignoreMessage, err := getPotentiallyValidManifestFile(filePath, info, appDir, appDir, "*.json", "")
			assert.Nil(t, realFileInfo)
			assert.Empty(t, ignoreMessage)
			require.NoError(t, err)
		})
	})

	t.Run("excluded file should be skipped with no message", func(t *testing.T) {
		appDir := tempDir(t)

		filePath := filepath.Join(appDir, "excluded.json")
		file, err := os.OpenFile(filePath, os.O_RDONLY|os.O_CREATE, 0o644)
		require.NoError(t, err)
		err = file.Close()
		require.NoError(t, err)

		walkFor(t, appDir, filePath, func(info fs.FileInfo) {
			realFileInfo, ignoreMessage, err := getPotentiallyValidManifestFile(filePath, info, appDir, appDir, "", "excluded.*")
			assert.Nil(t, realFileInfo)
			assert.Empty(t, ignoreMessage)
			require.NoError(t, err)
		})
	})

	t.Run("symlink to a regular file is potentially valid", func(t *testing.T) {
		appDir := tempDir(t)

		filePath := filepath.Join(appDir, "regular-file")
		file, err := os.OpenFile(filePath, os.O_RDONLY|os.O_CREATE, 0o644)
		require.NoError(t, err)
		err = file.Close()
		require.NoError(t, err)

		linkPath := filepath.Join(appDir, "link.json")
		err = os.Symlink(filePath, linkPath)
		require.NoError(t, err)

		walkFor(t, appDir, linkPath, func(info fs.FileInfo) {
			realFileInfo, ignoreMessage, err := getPotentiallyValidManifestFile(linkPath, info, appDir, appDir, "", "")
			assert.NotNil(t, realFileInfo)
			assert.Empty(t, ignoreMessage)
			require.NoError(t, err)
		})
	})

	t.Run("a regular file is potentially valid", func(t *testing.T) {
		appDir := tempDir(t)

		filePath := filepath.Join(appDir, "regular-file.json")
		file, err := os.OpenFile(filePath, os.O_RDONLY|os.O_CREATE, 0o644)
		require.NoError(t, err)
		err = file.Close()
		require.NoError(t, err)

		walkFor(t, appDir, filePath, func(info fs.FileInfo) {
			realFileInfo, ignoreMessage, err := getPotentiallyValidManifestFile(filePath, info, appDir, appDir, "", "")
			assert.NotNil(t, realFileInfo)
			assert.Empty(t, ignoreMessage)
			require.NoError(t, err)
		})
	})

	t.Run("realFileInfo is for the destination rather than the symlink", func(t *testing.T) {
		appDir := tempDir(t)

		filePath := filepath.Join(appDir, "regular-file")
		file, err := os.OpenFile(filePath, os.O_RDONLY|os.O_CREATE, 0o644)
		require.NoError(t, err)
		err = file.Close()
		require.NoError(t, err)

		linkPath := filepath.Join(appDir, "link.json")
		err = os.Symlink(filePath, linkPath)
		require.NoError(t, err)

		walkFor(t, appDir, linkPath, func(info fs.FileInfo) {
			realFileInfo, ignoreMessage, err := getPotentiallyValidManifestFile(linkPath, info, appDir, appDir, "", "")
			assert.NotNil(t, realFileInfo)
			assert.Equal(t, filepath.Base(filePath), realFileInfo.Name())
			assert.Empty(t, ignoreMessage)
			require.NoError(t, err)
		})
	})
}

func Test_getPotentiallyValidManifests(t *testing.T) {
	// Tests which return no manifests and an error check to make sure the directory exists before running. A missing
	// directory would produce those same results.

	logCtx := log.WithField("test", "test")

	t.Run("unreadable file throws error", func(t *testing.T) {
		appDir := t.TempDir()
		unreadablePath := filepath.Join(appDir, "unreadable.json")
		err := os.WriteFile(unreadablePath, []byte{}, 0o666)
		require.NoError(t, err)
		err = os.Chmod(appDir, 0o000)
		require.NoError(t, err)

		manifests, err := getPotentiallyValidManifests(logCtx, appDir, appDir, false, "", "", resource.MustParse("0"))
		assert.Empty(t, manifests)
		require.Error(t, err)

		// allow cleanup
		err = os.Chmod(appDir, 0o777)
		if err != nil {
			panic(err)
		}
	})

	t.Run("no recursion when recursion is disabled", func(t *testing.T) {
		manifests, err := getPotentiallyValidManifests(logCtx, "./testdata/recurse", "./testdata/recurse", false, "", "", resource.MustParse("0"))
		assert.Len(t, manifests, 1)
		require.NoError(t, err)
	})

	t.Run("recursion when recursion is enabled", func(t *testing.T) {
		manifests, err := getPotentiallyValidManifests(logCtx, "./testdata/recurse", "./testdata/recurse", true, "", "", resource.MustParse("0"))
		assert.Len(t, manifests, 2)
		require.NoError(t, err)
	})

	t.Run("non-JSON/YAML is skipped", func(t *testing.T) {
		manifests, err := getPotentiallyValidManifests(logCtx, "./testdata/non-manifest-file", "./testdata/non-manifest-file", false, "", "", resource.MustParse("0"))
		assert.Empty(t, manifests)
		require.NoError(t, err)
	})

	t.Run("circular link should throw an error", func(t *testing.T) {
		const testDir = "./testdata/circular-link"
		require.DirExists(t, testDir)
		t.Cleanup(func() {
			os.Remove(path.Join(testDir, "a.json"))
			os.Remove(path.Join(testDir, "b.json"))
		})
		t.Chdir(testDir)
		require.NoError(t, fileutil.CreateSymlink(t, "a.json", "b.json"))
		require.NoError(t, fileutil.CreateSymlink(t, "b.json", "a.json"))
		manifests, err := getPotentiallyValidManifests(logCtx, "./testdata/circular-link", "./testdata/circular-link", false, "", "", resource.MustParse("0"))
		assert.Empty(t, manifests)
		require.Error(t, err)
	})

	t.Run("out-of-bounds symlink should throw an error", func(t *testing.T) {
		require.DirExists(t, "./testdata/out-of-bounds-link")
		manifests, err := getPotentiallyValidManifests(logCtx, "./testdata/out-of-bounds-link", "./testdata/out-of-bounds-link", false, "", "", resource.MustParse("0"))
		assert.Empty(t, manifests)
		require.Error(t, err)
	})

	t.Run("symlink to a regular file works", func(t *testing.T) {
		repoRoot, err := filepath.Abs("./testdata/in-bounds-link")
		require.NoError(t, err)
		appPath, err := filepath.Abs("./testdata/in-bounds-link/app")
		require.NoError(t, err)
		manifests, err := getPotentiallyValidManifests(logCtx, appPath, repoRoot, false, "", "", resource.MustParse("0"))
		assert.Len(t, manifests, 1)
		require.NoError(t, err)
	})

	t.Run("symlink to nowhere should be ignored", func(t *testing.T) {
		manifests, err := getPotentiallyValidManifests(logCtx, "./testdata/link-to-nowhere", "./testdata/link-to-nowhere", false, "", "", resource.MustParse("0"))
		assert.Empty(t, manifests)
		require.NoError(t, err)
	})

	t.Run("link to over-sized manifest fails", func(t *testing.T) {
		repoRoot, err := filepath.Abs("./testdata/in-bounds-link")
		require.NoError(t, err)
		appPath, err := filepath.Abs("./testdata/in-bounds-link/app")
		require.NoError(t, err)
		// The file is 35 bytes.
		manifests, err := getPotentiallyValidManifests(logCtx, appPath, repoRoot, false, "", "", resource.MustParse("34"))
		assert.Empty(t, manifests)
		assert.ErrorIs(t, err, ErrExceededMaxCombinedManifestFileSize)
	})

	t.Run("group of files should be limited at precisely the sum of their size", func(t *testing.T) {
		// There is a total of 10 files, ech file being 10 bytes.
		manifests, err := getPotentiallyValidManifests(logCtx, "./testdata/several-files", "./testdata/several-files", false, "", "", resource.MustParse("365"))
		assert.Len(t, manifests, 10)
		require.NoError(t, err)

		manifests, err = getPotentiallyValidManifests(logCtx, "./testdata/several-files", "./testdata/several-files", false, "", "", resource.MustParse("100"))
		assert.Empty(t, manifests)
		assert.ErrorIs(t, err, ErrExceededMaxCombinedManifestFileSize)
	})
}

func Test_findManifests(t *testing.T) {
	logCtx := log.WithField("test", "test")
	noRecurse := v1alpha1.ApplicationSourceDirectory{Recurse: false}

	t.Run("unreadable file throws error", func(t *testing.T) {
		appDir := t.TempDir()
		unreadablePath := filepath.Join(appDir, "unreadable.json")
		err := os.WriteFile(unreadablePath, []byte{}, 0o666)
		require.NoError(t, err)
		err = os.Chmod(appDir, 0o000)
		require.NoError(t, err)

		manifests, err := findManifests(logCtx, appDir, appDir, nil, noRecurse, nil, resource.MustParse("0"))
		assert.Empty(t, manifests)
		require.Error(t, err)

		// allow cleanup
		err = os.Chmod(appDir, 0o777)
		if err != nil {
			panic(err)
		}
	})

	t.Run("no recursion when recursion is disabled", func(t *testing.T) {
		manifests, err := findManifests(logCtx, "./testdata/recurse", "./testdata/recurse", nil, noRecurse, nil, resource.MustParse("0"))
		assert.Len(t, manifests, 2)
		require.NoError(t, err)
	})

	t.Run("recursion when recursion is enabled", func(t *testing.T) {
		recurse := v1alpha1.ApplicationSourceDirectory{Recurse: true}
		manifests, err := findManifests(logCtx, "./testdata/recurse", "./testdata/recurse", nil, recurse, nil, resource.MustParse("0"))
		assert.Len(t, manifests, 4)
		require.NoError(t, err)
	})

	t.Run("non-JSON/YAML is skipped", func(t *testing.T) {
		manifests, err := findManifests(logCtx, "./testdata/non-manifest-file", "./testdata/non-manifest-file", nil, noRecurse, nil, resource.MustParse("0"))
		assert.Empty(t, manifests)
		require.NoError(t, err)
	})

	t.Run("circular link should throw an error", func(t *testing.T) {
		const testDir = "./testdata/circular-link"
		require.DirExists(t, testDir)
		t.Cleanup(func() {
			os.Remove(path.Join(testDir, "a.json"))
			os.Remove(path.Join(testDir, "b.json"))
		})
		t.Chdir(testDir)
		require.NoError(t, fileutil.CreateSymlink(t, "a.json", "b.json"))
		require.NoError(t, fileutil.CreateSymlink(t, "b.json", "a.json"))
		manifests, err := findManifests(logCtx, "./testdata/circular-link", "./testdata/circular-link", nil, noRecurse, nil, resource.MustParse("0"))
		assert.Empty(t, manifests)
		require.Error(t, err)
	})

	t.Run("out-of-bounds symlink should throw an error", func(t *testing.T) {
		require.DirExists(t, "./testdata/out-of-bounds-link")
		manifests, err := findManifests(logCtx, "./testdata/out-of-bounds-link", "./testdata/out-of-bounds-link", nil, noRecurse, nil, resource.MustParse("0"))
		assert.Empty(t, manifests)
		require.Error(t, err)
	})

	t.Run("symlink to a regular file works", func(t *testing.T) {
		repoRoot, err := filepath.Abs("./testdata/in-bounds-link")
		require.NoError(t, err)
		appPath, err := filepath.Abs("./testdata/in-bounds-link/app")
		require.NoError(t, err)
		manifests, err := findManifests(logCtx, appPath, repoRoot, nil, noRecurse, nil, resource.MustParse("0"))
		assert.Len(t, manifests, 1)
		require.NoError(t, err)
	})

	t.Run("symlink to nowhere should be ignored", func(t *testing.T) {
		manifests, err := findManifests(logCtx, "./testdata/link-to-nowhere", "./testdata/link-to-nowhere", nil, noRecurse, nil, resource.MustParse("0"))
		assert.Empty(t, manifests)
		require.NoError(t, err)
	})

	t.Run("link to over-sized manifest fails", func(t *testing.T) {
		repoRoot, err := filepath.Abs("./testdata/in-bounds-link")
		require.NoError(t, err)
		appPath, err := filepath.Abs("./testdata/in-bounds-link/app")
		require.NoError(t, err)
		// The file is 35 bytes.
		manifests, err := findManifests(logCtx, appPath, repoRoot, nil, noRecurse, nil, resource.MustParse("34"))
		assert.Empty(t, manifests)
		assert.ErrorIs(t, err, ErrExceededMaxCombinedManifestFileSize)
	})

	t.Run("group of files should be limited at precisely the sum of their size", func(t *testing.T) {
		// There is a total of 10 files, each file being 10 bytes.
		manifests, err := findManifests(logCtx, "./testdata/several-files", "./testdata/several-files", nil, noRecurse, nil, resource.MustParse("365"))
		assert.Len(t, manifests, 10)
		require.NoError(t, err)

		manifests, err = findManifests(logCtx, "./testdata/several-files", "./testdata/several-files", nil, noRecurse, nil, resource.MustParse("364"))
		assert.Empty(t, manifests)
		assert.ErrorIs(t, err, ErrExceededMaxCombinedManifestFileSize)
	})

	t.Run("jsonnet isn't counted against size limit", func(t *testing.T) {
		// Each file is 36 bytes. Only the 36-byte json file should be counted against the limit.
		manifests, err := findManifests(logCtx, "./testdata/jsonnet-and-json", "./testdata/jsonnet-and-json", nil, noRecurse, nil, resource.MustParse("36"))
		assert.Len(t, manifests, 2)
		require.NoError(t, err)

		manifests, err = findManifests(logCtx, "./testdata/jsonnet-and-json", "./testdata/jsonnet-and-json", nil, noRecurse, nil, resource.MustParse("35"))
		assert.Empty(t, manifests)
		assert.ErrorIs(t, err, ErrExceededMaxCombinedManifestFileSize)
	})

	t.Run("partially valid YAML file throws an error", func(t *testing.T) {
		require.DirExists(t, "./testdata/partially-valid-yaml")
		manifests, err := findManifests(logCtx, "./testdata/partially-valid-yaml", "./testdata/partially-valid-yaml", nil, noRecurse, nil, resource.MustParse("0"))
		assert.Empty(t, manifests)
		require.Error(t, err)
	})

	t.Run("invalid manifest throws an error", func(t *testing.T) {
		require.DirExists(t, "./testdata/invalid-manifests")
		manifests, err := findManifests(logCtx, "./testdata/invalid-manifests", "./testdata/invalid-manifests", nil, noRecurse, nil, resource.MustParse("0"))
		assert.Empty(t, manifests)
		require.Error(t, err)
	})

	t.Run("invalid manifest containing '+argocd:skip-file-rendering' doesn't throw an error", func(t *testing.T) {
		require.DirExists(t, "./testdata/invalid-manifests-skipped")
		manifests, err := findManifests(logCtx, "./testdata/invalid-manifests-skipped", "./testdata/invalid-manifests-skipped", nil, noRecurse, nil, resource.MustParse("0"))
		assert.Empty(t, manifests)
		require.NoError(t, err)
	})

	t.Run("irrelevant YAML gets skipped, relevant YAML gets parsed", func(t *testing.T) {
		manifests, err := findManifests(logCtx, "./testdata/irrelevant-yaml", "./testdata/irrelevant-yaml", nil, noRecurse, nil, resource.MustParse("0"))
		assert.Len(t, manifests, 1)
		require.NoError(t, err)
	})

	t.Run("multiple JSON objects in one file throws an error", func(t *testing.T) {
		require.DirExists(t, "./testdata/json-list")
		manifests, err := findManifests(logCtx, "./testdata/json-list", "./testdata/json-list", nil, noRecurse, nil, resource.MustParse("0"))
		assert.Empty(t, manifests)
		require.Error(t, err)
	})

	t.Run("invalid JSON throws an error", func(t *testing.T) {
		require.DirExists(t, "./testdata/invalid-json")
		manifests, err := findManifests(logCtx, "./testdata/invalid-json", "./testdata/invalid-json", nil, noRecurse, nil, resource.MustParse("0"))
		assert.Empty(t, manifests)
		require.Error(t, err)
	})

	t.Run("valid JSON returns manifest and no error", func(t *testing.T) {
		manifests, err := findManifests(logCtx, "./testdata/valid-json", "./testdata/valid-json", nil, noRecurse, nil, resource.MustParse("0"))
		assert.Len(t, manifests, 1)
		require.NoError(t, err)
	})

	t.Run("YAML with an empty document doesn't throw an error", func(t *testing.T) {
		manifests, err := findManifests(logCtx, "./testdata/yaml-with-empty-document", "./testdata/yaml-with-empty-document", nil, noRecurse, nil, resource.MustParse("0"))
		assert.Len(t, manifests, 1)
		require.NoError(t, err)
	})
}

func TestTestRepoHelmOCI(t *testing.T) {
	service := newService(t, ".")
	_, err := service.TestRepository(t.Context(), &apiclient.TestRepositoryRequest{
		Repo: &v1alpha1.Repository{
			Repo:      "https://demo.goharbor.io",
			Type:      "helm",
			EnableOCI: true,
		},
	})
	assert.ErrorContains(t, err, "OCI Helm repository URL should include hostname and port only")
}

func Test_getHelmDependencyRepos(t *testing.T) {
	repo1 := "https://charts.bitnami.com/bitnami"
	repo2 := "https://eventstore.github.io/EventStore.Charts"

	repos, err := getHelmDependencyRepos("../../util/helm/testdata/dependency")
	require.NoError(t, err)
	assert.Len(t, repos, 2)
	assert.Equal(t, repos[0].Repo, repo1)
	assert.Equal(t, repos[1].Repo, repo2)
}

func TestResolveRevision(t *testing.T) {
	service := newService(t, ".")
	repo := &v1alpha1.Repository{Repo: "https://github.com/argoproj/argo-cd"}
	app := &v1alpha1.Application{Spec: v1alpha1.ApplicationSpec{Source: &v1alpha1.ApplicationSource{}}}
	resolveRevisionResponse, err := service.ResolveRevision(t.Context(), &apiclient.ResolveRevisionRequest{
		Repo:              repo,
		App:               app,
		AmbiguousRevision: "v2.2.2",
	})

	expectedResolveRevisionResponse := &apiclient.ResolveRevisionResponse{
		Revision:          "03b17e0233e64787ffb5fcf65c740cc2a20822ba",
		AmbiguousRevision: "v2.2.2 (03b17e0233e64787ffb5fcf65c740cc2a20822ba)",
	}

	assert.NotNil(t, resolveRevisionResponse.Revision)
	require.NoError(t, err)
	assert.Equal(t, expectedResolveRevisionResponse, resolveRevisionResponse)
}

func TestResolveRevisionNegativeScenarios(t *testing.T) {
	service := newService(t, ".")
	repo := &v1alpha1.Repository{Repo: "https://github.com/argoproj/argo-cd"}
	app := &v1alpha1.Application{Spec: v1alpha1.ApplicationSpec{Source: &v1alpha1.ApplicationSource{}}}
	resolveRevisionResponse, err := service.ResolveRevision(t.Context(), &apiclient.ResolveRevisionRequest{
		Repo:              repo,
		App:               app,
		AmbiguousRevision: "v2.a.2",
	})

	expectedResolveRevisionResponse := &apiclient.ResolveRevisionResponse{
		Revision:          "",
		AmbiguousRevision: "",
	}

	assert.NotNil(t, resolveRevisionResponse.Revision)
	require.Error(t, err)
	assert.Equal(t, expectedResolveRevisionResponse, resolveRevisionResponse)
}

func TestDirectoryPermissionInitializer(t *testing.T) {
	dir := t.TempDir()

	file, err := os.CreateTemp(dir, "")
	require.NoError(t, err)
	utilio.Close(file)

	// remove read permissions
	require.NoError(t, os.Chmod(dir, 0o000))

	// Remember to restore permissions when the test finishes so dir can
	// be removed properly.
	t.Cleanup(func() {
		require.NoError(t, os.Chmod(dir, 0o777))
	})

	// make sure permission are restored
	closer := directoryPermissionInitializer(dir)
	_, err = os.ReadFile(file.Name())
	require.NoError(t, err)

	// make sure permission are removed by closer
	utilio.Close(closer)
	_, err = os.ReadFile(file.Name())
	require.Error(t, err)
}

func addHelmToGitRepo(t *testing.T, options newGitRepoOptions) {
	t.Helper()
	ctx := t.Context()
	err := os.WriteFile(filepath.Join(options.path, "Chart.yaml"), []byte("name: test\nversion: v1.0.0"), 0o777)
	require.NoError(t, err)
	for valuesFileName, values := range options.helmChartOptions.valuesFiles {
		valuesFileContents, err := yaml.Marshal(values)
		require.NoError(t, err)
		err = os.WriteFile(filepath.Join(options.path, valuesFileName), valuesFileContents, 0o777)
		require.NoError(t, err)
	}
	require.NoError(t, err)
	cmd := exec.CommandContext(ctx, "git", "add", "-A")
	cmd.Dir = options.path
	require.NoError(t, cmd.Run())
	cmd = exec.CommandContext(ctx, "git", "commit", "-m", "Initial commit")
	cmd.Dir = options.path
	require.NoError(t, cmd.Run())
}

func initGitRepo(t *testing.T, options newGitRepoOptions) (revision string) {
	t.Helper()
	if options.createPath {
		require.NoError(t, os.Mkdir(options.path, 0o755))
	}
	ctx := t.Context()

	cmd := exec.CommandContext(ctx, "git", "init", "-b", "main", options.path)
	cmd.Dir = options.path
	require.NoError(t, cmd.Run())

	if options.remote != "" {
		cmd = exec.CommandContext(ctx, "git", "remote", "add", "origin", options.path)
		cmd.Dir = options.path
		require.NoError(t, cmd.Run())
	}

	commitAdded := options.addEmptyCommit || options.helmChartOptions.chartName != ""
	if options.addEmptyCommit {
		cmd = exec.CommandContext(ctx, "git", "commit", "-m", "Initial commit", "--allow-empty")
		cmd.Dir = options.path
		require.NoError(t, cmd.Run())
	} else if options.helmChartOptions.chartName != "" {
		addHelmToGitRepo(t, options)
	}

	if commitAdded {
		var revB bytes.Buffer
		cmd = exec.CommandContext(ctx, "git", "rev-parse", "HEAD", options.path)
		cmd.Dir = options.path
		cmd.Stdout = &revB
		require.NoError(t, cmd.Run())
		revision = strings.Split(revB.String(), "\n")[0]
	}
	return revision
}

func TestInit(t *testing.T) {
	dir := t.TempDir()

	// service.Init sets permission to 0300. Restore permissions when the test
	// finishes so dir can be removed properly.
	t.Cleanup(func() {
		require.NoError(t, os.Chmod(dir, 0o777))
	})

	repoPath := path.Join(dir, "repo1")
	initGitRepo(t, newGitRepoOptions{path: repoPath, remote: "https://github.com/argo-cd/test-repo1", createPath: true, addEmptyCommit: false})

	service := newService(t, ".")
	service.rootDir = dir

	require.NoError(t, service.Init())

	_, err := os.ReadDir(dir)
	require.Error(t, err)
	initGitRepo(t, newGitRepoOptions{path: path.Join(dir, "repo2"), remote: "https://github.com/argo-cd/test-repo2", createPath: true, addEmptyCommit: false})
}

// TestCheckoutRevisionCanGetNonstandardRefs shows that we can fetch a revision that points to a non-standard ref. In
// other words, we haven't regressed and caused this issue again: https://github.com/argoproj/argo-cd/issues/4935
func TestCheckoutRevisionCanGetNonstandardRefs(t *testing.T) {
	rootPath := t.TempDir()

	sourceRepoPath, err := os.MkdirTemp(rootPath, "")
	require.NoError(t, err)

	// Create a repo such that one commit is on a non-standard ref _and nowhere else_. This is meant to simulate, for
	// example, a GitHub ref for a pull into one repo from a fork of that repo.
	runGit(t, sourceRepoPath, "init")
	runGit(t, sourceRepoPath, "checkout", "-b", "main") // make sure there's a main branch to switch back to
	runGit(t, sourceRepoPath, "commit", "-m", "empty", "--allow-empty")
	runGit(t, sourceRepoPath, "checkout", "-b", "branch")
	runGit(t, sourceRepoPath, "commit", "-m", "empty", "--allow-empty")
	sha := runGit(t, sourceRepoPath, "rev-parse", "HEAD")
	runGit(t, sourceRepoPath, "update-ref", "refs/pull/123/head", strings.TrimSuffix(sha, "\n"))
	runGit(t, sourceRepoPath, "checkout", "main")
	runGit(t, sourceRepoPath, "branch", "-D", "branch")

	destRepoPath, err := os.MkdirTemp(rootPath, "")
	require.NoError(t, err)

	gitClient, err := git.NewClientExt("file://"+sourceRepoPath, destRepoPath, &git.NopCreds{}, true, false, "", "")
	require.NoError(t, err)

	pullSha, err := gitClient.LsRemote("refs/pull/123/head")
	require.NoError(t, err)

	err = checkoutRevision(gitClient, "does-not-exist", false, 0, true)
	require.Error(t, err)

	err = checkoutRevision(gitClient, pullSha, false, 0, true)
	require.NoError(t, err)
}

func TestCheckoutRevisionPresentSkipFetch(t *testing.T) {
	revision := "0123456789012345678901234567890123456789"

	gitClient := &gitmocks.Client{}
	gitClient.EXPECT().Init().Return(nil)
	gitClient.EXPECT().IsRevisionPresent(revision).Return(true)
	gitClient.EXPECT().Checkout(revision, mock.Anything, mock.Anything).Return("", nil)

	err := checkoutRevision(gitClient, revision, false, 0, true)
	require.NoError(t, err)
}

func TestCheckoutRevisionNotPresentCallFetch(t *testing.T) {
	revision := "0123456789012345678901234567890123456789"

	gitClient := &gitmocks.Client{}
	gitClient.EXPECT().Init().Return(nil)
	gitClient.EXPECT().IsRevisionPresent(revision).Return(false)
	gitClient.EXPECT().Fetch("", mock.Anything).Return(nil)
	gitClient.EXPECT().Checkout(revision, mock.Anything, mock.Anything).Return("", nil)

	err := checkoutRevision(gitClient, revision, false, 0, true)
	require.NoError(t, err)
}

func TestFetch(t *testing.T) {
	revision1 := "0123456789012345678901234567890123456789"
	revision2 := "abcdefabcdefabcdefabcdefabcdefabcdefabcd"

	gitClient := &gitmocks.Client{}
	gitClient.EXPECT().Init().Return(nil)
	gitClient.EXPECT().IsRevisionPresent(revision1).Once().Return(true)
	gitClient.EXPECT().IsRevisionPresent(revision2).Once().Return(false)
	gitClient.EXPECT().Fetch("", mock.Anything).Return(nil)
	gitClient.EXPECT().IsRevisionPresent(revision1).Once().Return(true)
	gitClient.EXPECT().IsRevisionPresent(revision2).Once().Return(true)

	err := fetch(gitClient, []string{revision1, revision2})
	require.NoError(t, err)
}

// TestFetchRevisionCanGetNonstandardRefs shows that we can fetch a revision that points to a non-standard ref. In
func TestFetchRevisionCanGetNonstandardRefs(t *testing.T) {
	rootPath := t.TempDir()

	sourceRepoPath, err := os.MkdirTemp(rootPath, "")
	require.NoError(t, err)

	// Create a repo such that one commit is on a non-standard ref _and nowhere else_. This is meant to simulate, for
	// example, a GitHub ref for a pull into one repo from a fork of that repo.
	runGit(t, sourceRepoPath, "init")
	runGit(t, sourceRepoPath, "checkout", "-b", "main") // make sure there's a main branch to switch back to
	runGit(t, sourceRepoPath, "commit", "-m", "empty", "--allow-empty")
	runGit(t, sourceRepoPath, "checkout", "-b", "branch")
	runGit(t, sourceRepoPath, "commit", "-m", "empty", "--allow-empty")
	sha := runGit(t, sourceRepoPath, "rev-parse", "HEAD")
	runGit(t, sourceRepoPath, "update-ref", "refs/pull/123/head", strings.TrimSuffix(sha, "\n"))
	runGit(t, sourceRepoPath, "checkout", "main")
	runGit(t, sourceRepoPath, "branch", "-D", "branch")

	destRepoPath, err := os.MkdirTemp(rootPath, "")
	require.NoError(t, err)

	gitClient, err := git.NewClientExt("file://"+sourceRepoPath, destRepoPath, &git.NopCreds{}, true, false, "", "")
	require.NoError(t, err)

	// We should initialize repository
	err = gitClient.Init()
	require.NoError(t, err)

	pullSha, err := gitClient.LsRemote("refs/pull/123/head")
	require.NoError(t, err)

	err = fetch(gitClient, []string{"does-not-exist"})
	require.Error(t, err)

	err = fetch(gitClient, []string{pullSha})
	require.NoError(t, err)
}

// runGit runs a git command in the given working directory. If the command succeeds, it returns the combined standard
// and error output. If it fails, it stops the test with a failure message.
func runGit(t *testing.T, workDir string, args ...string) string {
	t.Helper()
	cmd := exec.CommandContext(t.Context(), "git", args...)
	cmd.Dir = workDir
	out, err := cmd.CombinedOutput()
	stringOut := string(out)
	require.NoError(t, err, stringOut)
	return stringOut
}

func Test_walkHelmValueFilesInPath(t *testing.T) {
	t.Run("does not exist", func(t *testing.T) {
		var files []string
		root := "/obviously/does/not/exist"
		err := filepath.Walk(root, walkHelmValueFilesInPath(root, &files))
		require.Error(t, err)
		assert.Empty(t, files)
	})
	t.Run("values files", func(t *testing.T) {
		var files []string
		root := "./testdata/values-files"
		err := filepath.Walk(root, walkHelmValueFilesInPath(root, &files))
		require.NoError(t, err)
		assert.Len(t, files, 5)
	})
	t.Run("unrelated root", func(t *testing.T) {
		var files []string
		root := "./testdata/values-files"
		unrelatedRoot := "/different/root/path"
		err := filepath.Walk(root, walkHelmValueFilesInPath(unrelatedRoot, &files))
		require.Error(t, err)
	})
}

func Test_populateHelmAppDetails(t *testing.T) {
	sha := "632039659e542ed7de0c170a4fcc1c571b288fc0"
	service := newService(t, ".")
	emptyTempPaths := utilio.NewRandomizedTempPaths(t.TempDir())
	res := apiclient.RepoAppDetailsResponse{}
	q := apiclient.RepoServerAppDetailsQuery{
		Repo: &v1alpha1.Repository{},
		Source: &v1alpha1.ApplicationSource{
			Helm: &v1alpha1.ApplicationSourceHelm{ValueFiles: []string{"exclude.yaml", "has-the-word-values.yaml"}},
		},
	}
	appPath, err := filepath.Abs("./testdata/values-files/")
	require.NoError(t, err)
	err = service.populateHelmAppDetails(&res, appPath, appPath, sha, "main", &q, emptyTempPaths)
	require.NoError(t, err)
	assert.Len(t, res.Helm.Parameters, 3)
	assert.Len(t, res.Helm.ValueFiles, 5)
}

func Test_populateHelmAppDetailsWithRef(t *testing.T) {
	dummyErrMsg := "dummy error"
	repoURL := "https://github.com/foo/bar"
	refRepoURL := "https://github.com/foo/baz"
	unusedRefRepoURL := "https://github.com/unused/baz"
	ociRepoURL := "oci://foocr.io"
	repoRoot := "./testdata/my-chart/"
	refRoot := "./testdata/values-files/"
	refName := "$values"
	refNameA := "$valuesA"
	refNameB := "$valuesB"
	refNameUnused := "$valuesU"
	targetRevision := "main"
	sha := "888839659e542ed7de0c170a4fcc1c571b288888"
	refTargetRevision := targetRevision
	refTargetRevision2 := "dev"
	refSha := "999932039659e542ed7de0c170a4fcc1c5799999"
	refSha2 := "777732039659e542ed7de0c170a4fcc1c5777777"
	queryTemplate := apiclient.RepoServerAppDetailsQuery{
		Repo: &v1alpha1.Repository{
			Repo: repoURL,
			Type: "git",
		},
		Source: &v1alpha1.ApplicationSource{
			Helm: &v1alpha1.ApplicationSourceHelm{ValueFiles: []string{"$values/dir/values.yaml"}},
		},
		RefSources: map[string]*v1alpha1.RefTarget{
			refName: {
				Repo: v1alpha1.Repository{
					Type: "git",
					Repo: refRepoURL,
				},
				TargetRevision: refTargetRevision,
			},
		},
	}
	var err error
	var appPath string
	var res apiclient.RepoAppDetailsResponse

	testCases := []struct {
		name        string
		makeQuery   func() apiclient.RepoServerAppDetailsQuery
		testResults func(t *testing.T)
		mockOpts    func(_ *gitmocks.Client, _ *helmmocks.Client, _ *ocimocks.Client, paths *iomocks.TempPaths)
		// make new client for accessing the referenced repository
		newGitClient func(_ string, _ string, _ git.Creds, _ bool, _ bool, _ string, _ string, _ ...git.ClientOpts) (client git.Client, e error)
	}{
		{
			name: "success",
			makeQuery: func() apiclient.RepoServerAppDetailsQuery {
				return queryTemplate
			},
			mockOpts: func(_ *gitmocks.Client, _ *helmmocks.Client, _ *ocimocks.Client, paths *iomocks.TempPaths) {
				paths.EXPECT().GetPath(refRepoURL).Return(refRoot, nil)
				paths.EXPECT().GetPathIfExists(refRepoURL).Return(refRoot)
			},
			newGitClient: func(_ string, _ string, _ git.Creds, _ bool, _ bool, _ string, _ string, _ ...git.ClientOpts) (gitClient git.Client, e error) {
				client := gitmocks.Client{}
				client.EXPECT().LsRemote(refTargetRevision).Return(refSha, nil)
				client.EXPECT().Root().Return(refRoot)
				client.EXPECT().Init().Return(nil)
				client.EXPECT().IsRevisionPresent(refSha).Return(true)
				client.EXPECT().Checkout(refSha, false, true).Return("", nil)
				return &client, nil
			},

			testResults: func(t *testing.T) {
				t.Helper()
				require.NoError(t, err)
				assert.Len(t, res.Helm.Parameters, 1)
				// The values must come from the referenced values file ./testdata/values-files/dir/values.yaml
				for _, v := range res.Helm.Parameters {
					require.NotNil(t, v)
					assert.Equal(t, v1alpha1.HelmParameter{Name: "values", Value: "yaml", ForceString: false}, *v)
				}
			},
		},
		{
			name: "ref_checkout_error",
			makeQuery: func() apiclient.RepoServerAppDetailsQuery {
				return queryTemplate
			},
			mockOpts: func(_ *gitmocks.Client, _ *helmmocks.Client, _ *ocimocks.Client, paths *iomocks.TempPaths) {
				paths.EXPECT().GetPath(refRepoURL).Return(refRoot, nil)
			},
			newGitClient: func(_ string, _ string, _ git.Creds, _ bool, _ bool, _ string, _ string, _ ...git.ClientOpts) (gitClient git.Client, e error) {
				client := gitmocks.Client{}
				client.EXPECT().LsRemote("main").Return(refSha, nil)
				client.EXPECT().Root().Return(refRoot)
				client.EXPECT().Init().Return(nil)
				client.EXPECT().IsRevisionPresent(refSha).Return(true)
				client.EXPECT().Checkout(refSha, false, true).Return("", fmt.Errorf("%s", dummyErrMsg))
				// one error is not enough: checkout falls back to fetch specific revision
				client.EXPECT().Fetch(refSha, int64(0)).Return(fmt.Errorf("%s", dummyErrMsg))
				return &client, nil
			},

			testResults: func(t *testing.T) {
				t.Helper()
				require.Error(t, err)
				require.ErrorContains(t, err, fmt.Sprintf("failed to acquire lock for referenced repo %q:", refRepoURL))
				assert.ErrorContains(t, err, dummyErrMsg)
			},
		},
		{
			name: "same_repo_diff_revision_error",
			makeQuery: func() apiclient.RepoServerAppDetailsQuery {
				query := queryTemplate
				query.RefSources = map[string]*v1alpha1.RefTarget{
					refName: {
						Repo: v1alpha1.Repository{
							Type: "git",
							Repo: repoURL,
						},
						TargetRevision: refTargetRevision2,
					},
				}
				return query
			},

			mockOpts: func(_ *gitmocks.Client, _ *helmmocks.Client, _ *ocimocks.Client, paths *iomocks.TempPaths) {
				paths.EXPECT().GetPath(repoURL).Return(repoRoot, nil)
			},
			newGitClient: func(_ string, _ string, _ git.Creds, _ bool, _ bool, _ string, _ string, _ ...git.ClientOpts) (gitClient git.Client, e error) {
				client := gitmocks.Client{}
				client.EXPECT().LsRemote(refTargetRevision2).Return(refSha, nil)
				return &client, nil
			},

			testResults: func(t *testing.T) {
				t.Helper()
				expMsg := fmt.Sprintf("cannot reference a different revision of the same repository (%s references %q which resolves to %q while the application references %q which resolves to %q", refName, refTargetRevision2, refSha, targetRevision, sha)
				require.Error(t, err)
				require.ErrorContains(t, err, expMsg)
			},
		},
		{
			name: "same_ref_repo_diff_revision_error",
			makeQuery: func() apiclient.RepoServerAppDetailsQuery {
				query := queryTemplate
				delete(query.RefSources, "$values")
				query.RefSources["$valuesA"] = &v1alpha1.RefTarget{
					Repo: v1alpha1.Repository{
						Type: "git",
						Repo: refRepoURL,
					},
					TargetRevision: refTargetRevision,
				}
				query.RefSources["$valuesB"] = &v1alpha1.RefTarget{
					Repo: v1alpha1.Repository{
						Type: "git",
						Repo: refRepoURL,
					},
					TargetRevision: refTargetRevision2,
				}
				query.Source.Helm.ValueFiles = []string{"$valuesA/dir/values.yaml", "$valuesB/dir/values.yaml"}
				return query
			},

			mockOpts: func(_ *gitmocks.Client, _ *helmmocks.Client, _ *ocimocks.Client, paths *iomocks.TempPaths) {
				paths.EXPECT().GetPath(repoURL).Return(repoRoot, nil)
				paths.EXPECT().GetPath(refRepoURL).Return(refRoot, nil)
				paths.EXPECT().GetPath(refRepoURL).Return(refRoot, nil)
			},
			newGitClient: func(_ string, _ string, _ git.Creds, _ bool, _ bool, _ string, _ string, _ ...git.ClientOpts) (gitClient git.Client, e error) {
				client := gitmocks.Client{}
				client.EXPECT().LsRemote("main").Return(refSha, nil)
				client.EXPECT().LsRemote("dev").Return(refSha2, nil)
				client.EXPECT().Root().Return(refRoot)
				client.EXPECT().Init().Return(nil)
				client.EXPECT().IsRevisionPresent(refSha).Return(true)
				client.EXPECT().IsRevisionPresent(refSha2).Return(true)
				client.EXPECT().Checkout(refSha, false, true).Return("", nil)
				client.EXPECT().Checkout(refSha2, false, true).Return("", nil)
				return &client, nil
			},

			testResults: func(t *testing.T) {
				t.Helper()
				expMsg := fmt.Sprintf("cannot reference multiple revisions for the same repository (%s references %q which resolves to %q while %s references %q which resolves to %q", refNameB, refTargetRevision2, refSha2, refNameA, refTargetRevision, refSha)
				require.Error(t, err)
				require.ErrorContains(t, err, expMsg)
			},
		},
		{
			name: "ref_revision_resolution_error",
			makeQuery: func() apiclient.RepoServerAppDetailsQuery {
				return queryTemplate
			},
			mockOpts: func(_ *gitmocks.Client, _ *helmmocks.Client, _ *ocimocks.Client, paths *iomocks.TempPaths) {
				// paths.EXPECT().GetPath(repoURL).Return(repoRoot, nil)
				paths.EXPECT().GetPath(refRepoURL).Return(refRoot, nil)
			},
			newGitClient: func(_ string, _ string, _ git.Creds, _ bool, _ bool, _ string, _ string, _ ...git.ClientOpts) (gitClient git.Client, e error) {
				client := gitmocks.Client{}
				client.EXPECT().LsRemote("main").Return("", fmt.Errorf("%s", dummyErrMsg))
				client.EXPECT().Root().Return(refRoot)
				return &client, nil
			},
			testResults: func(t *testing.T) {
				t.Helper()
				expMsg := fmt.Sprintf("error setting up git client for %s and resolving revision %s: %s", refRepoURL, "main", dummyErrMsg)
				require.Error(t, err)
				require.ErrorContains(t, err, expMsg)
			},
		},
		{
			name: "not_a_git_referenced_repo",
			makeQuery: func() apiclient.RepoServerAppDetailsQuery {
				query := queryTemplate
				query.RefSources = map[string]*v1alpha1.RefTarget{
					refName: {
						Repo: v1alpha1.Repository{
							Type: "oci",
							Repo: ociRepoURL,
						},
						TargetRevision: refTargetRevision2,
					},
				}
				return query
			},
			mockOpts: func(_ *gitmocks.Client, _ *helmmocks.Client, _ *ocimocks.Client, paths *iomocks.TempPaths) {
				paths.EXPECT().GetPath(repoURL).Return(repoRoot, nil)
				paths.EXPECT().GetPathIfExists(ociRepoURL).Return("")
			},
			newGitClient: func(_ string, _ string, _ git.Creds, _ bool, _ bool, _ string, _ string, _ ...git.ClientOpts) (gitClient git.Client, e error) {
				client := gitmocks.Client{}
				return &client, nil
			},
			testResults: func(t *testing.T) {
				t.Helper()
				require.Error(t, fmt.Errorf("failed to find repo %q", ociRepoURL))
			},
		},
		{
			name: "unused_refsource_is_not_checked_out",
			makeQuery: func() apiclient.RepoServerAppDetailsQuery {
				q := queryTemplate
				// Add a second ref source but do NOT reference it in ValueFiles.
				q.RefSources = map[string]*v1alpha1.RefTarget{
					refName: {
						Repo: v1alpha1.Repository{
							Type: "git",
							Repo: refRepoURL,
						},
						TargetRevision: refTargetRevision,
					},
					refNameUnused: {
						Repo: v1alpha1.Repository{
							Type: "git",
							Repo: unusedRefRepoURL,
						},
						TargetRevision: "main",
					},
				}
				// Keep ValueFiles referencing only $values
				q.Source.Helm.ValueFiles = []string{"$values/dir/values.yaml"}
				return q
			},
			mockOpts: func(_ *gitmocks.Client, _ *helmmocks.Client, _ *ocimocks.Client, paths *iomocks.TempPaths) {
				paths.EXPECT().GetPath(refRepoURL).Return(refRoot, nil)
				paths.EXPECT().GetPathIfExists(refRepoURL).Return(refRoot)
				// No expectations for "https://github.com/foo/unused" on purpose: it should not be used.
			},
			newGitClient: func(repo string, _ string, _ git.Creds, _ bool, _ bool, _ string, _ string, _ ...git.ClientOpts) (git.Client, error) {
				if repo == unusedRefRepoURL {
					return nil, fmt.Errorf("newGitClient should not be called for unused ref source: %s", repo)
				}

				client := gitmocks.Client{}
				client.EXPECT().LsRemote(refTargetRevision).Return(refSha, nil)
				client.EXPECT().Root().Return(refRoot)
				client.EXPECT().Init().Return(nil)
				client.EXPECT().IsRevisionPresent(refSha).Return(true)
				client.EXPECT().Checkout(refSha, false, true).Return("", nil)
				return &client, nil
			},
			testResults: func(t *testing.T) {
				t.Helper()
				require.NoError(t, err)
				assert.Len(t, res.Helm.Parameters, 1)
			},
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			query := tc.makeQuery()
			service, _, _ := newServiceWithOpt(t, tc.mockOpts, ".")
			service.newGitClient = tc.newGitClient
			appPath, err = filepath.Abs(repoRoot)
			require.NoError(t, err)
			res = apiclient.RepoAppDetailsResponse{}
			err = service.populateHelmAppDetails(&res, appPath, appPath, sha, "main", &query, service.gitRepoPaths)
			tc.testResults(t)
		})
	}
}

func Test_populateHelmAppDetails_values_symlinks(t *testing.T) {
	service := newService(t, ".")
	sha := "632039659e542ed7de0c170a4fcc1c571b288fc0"
	emptyTempPaths := utilio.NewRandomizedTempPaths(t.TempDir())
	t.Run("inbound", func(t *testing.T) {
		res := apiclient.RepoAppDetailsResponse{}
		q := apiclient.RepoServerAppDetailsQuery{Repo: &v1alpha1.Repository{}, Source: &v1alpha1.ApplicationSource{}}
		err := service.populateHelmAppDetails(&res, "./testdata/in-bounds-values-file-link/", "./testdata/in-bounds-values-file-link/", "dummy_sha", "main", &q, emptyTempPaths)
		require.NoError(t, err)
		assert.NotEmpty(t, res.Helm.Values)
		assert.NotEmpty(t, res.Helm.Parameters)
	})

	t.Run("out of bounds", func(t *testing.T) {
		res := apiclient.RepoAppDetailsResponse{}
		q := apiclient.RepoServerAppDetailsQuery{Repo: &v1alpha1.Repository{}, Source: &v1alpha1.ApplicationSource{}}
		err := service.populateHelmAppDetails(&res, "./testdata/out-of-bounds-values-file-link/", "./testdata/out-of-bounds-values-file-link/", sha, "main", &q, emptyTempPaths)
		require.NoError(t, err)
		assert.Empty(t, res.Helm.Values)
		assert.Empty(t, res.Helm.Parameters)
	})
}

func TestGetHelmRepos_OCIHelmDependenciesWithHelmRepo(t *testing.T) {
	q := apiclient.ManifestRequest{Repos: []*v1alpha1.Repository{}, HelmRepoCreds: []*v1alpha1.RepoCreds{
		{URL: "example.com", Username: "test", Password: "test", EnableOCI: true},
	}}

	helmRepos, err := getHelmRepos("./testdata/oci-dependencies", q.Repos, q.HelmRepoCreds)
	require.NoError(t, err)

	assert.Len(t, helmRepos, 1)
	assert.Equal(t, "test", helmRepos[0].GetUsername())
	assert.True(t, helmRepos[0].EnableOci)
	assert.Equal(t, "example.com/myrepo", helmRepos[0].Repo)
}

func TestGetHelmRepos_OCIHelmDependenciesWithRepo(t *testing.T) {
	q := apiclient.ManifestRequest{Repos: []*v1alpha1.Repository{{Repo: "example.com", Username: "test", Password: "test", EnableOCI: true}}, HelmRepoCreds: []*v1alpha1.RepoCreds{}}

	helmRepos, err := getHelmRepos("./testdata/oci-dependencies", q.Repos, q.HelmRepoCreds)
	require.NoError(t, err)

	assert.Len(t, helmRepos, 1)
	assert.Equal(t, "test", helmRepos[0].GetUsername())
	assert.True(t, helmRepos[0].EnableOci)
	assert.Equal(t, "example.com/myrepo", helmRepos[0].Repo)
}

func TestGetHelmRepos_OCIDependenciesWithHelmRepo(t *testing.T) {
	q := apiclient.ManifestRequest{Repos: []*v1alpha1.Repository{}, HelmRepoCreds: []*v1alpha1.RepoCreds{
		{URL: "oci://example.com", Username: "test", Password: "test", Type: "oci"},
	}}

	helmRepos, err := getHelmRepos("./testdata/oci-dependencies", q.Repos, q.HelmRepoCreds)
	require.NoError(t, err)

	assert.Len(t, helmRepos, 1)
	assert.Equal(t, "test", helmRepos[0].GetUsername())
	assert.True(t, helmRepos[0].EnableOci)
	assert.Equal(t, "example.com/myrepo", helmRepos[0].Repo)
}

func TestGetHelmRepos_OCIDependenciesWithRepo(t *testing.T) {
	q := apiclient.ManifestRequest{Repos: []*v1alpha1.Repository{{Repo: "oci://example.com", Username: "test", Password: "test", Type: "oci"}}, HelmRepoCreds: []*v1alpha1.RepoCreds{}}

	helmRepos, err := getHelmRepos("./testdata/oci-dependencies", q.Repos, q.HelmRepoCreds)
	require.NoError(t, err)

	assert.Len(t, helmRepos, 1)
	assert.Equal(t, "test", helmRepos[0].GetUsername())
	assert.True(t, helmRepos[0].EnableOci)
	assert.Equal(t, "example.com/myrepo", helmRepos[0].Repo)
}

func TestGetHelmRepo_NamedRepos(t *testing.T) {
	q := apiclient.ManifestRequest{
		Repos: []*v1alpha1.Repository{{
			Name:     "custom-repo",
			Repo:     "https://example.com",
			Username: "test",
		}},
	}

	helmRepos, err := getHelmRepos("./testdata/helm-with-dependencies", q.Repos, q.HelmRepoCreds)
	require.NoError(t, err)

	assert.Len(t, helmRepos, 1)
	assert.Equal(t, "test", helmRepos[0].GetUsername())
	assert.Equal(t, "https://example.com", helmRepos[0].Repo)
}

func TestGetHelmRepo_NamedReposAlias(t *testing.T) {
	q := apiclient.ManifestRequest{
		Repos: []*v1alpha1.Repository{{
			Name:     "custom-repo-alias",
			Repo:     "https://example.com",
			Username: "test-alias",
		}},
	}

	helmRepos, err := getHelmRepos("./testdata/helm-with-dependencies-alias", q.Repos, q.HelmRepoCreds)
	require.NoError(t, err)

	assert.Len(t, helmRepos, 1)
	assert.Equal(t, "test-alias", helmRepos[0].GetUsername())
	assert.Equal(t, "https://example.com", helmRepos[0].Repo)
}

func Test_getResolvedValueFiles(t *testing.T) {
	t.Parallel()

	tempDir := t.TempDir()
	paths := utilio.NewRandomizedTempPaths(tempDir)

	paths.Add(git.NormalizeGitURL("https://github.com/org/repo1"), path.Join(tempDir, "repo1"))

	testCases := []struct {
		name         string
		rawPath      string
		env          *v1alpha1.Env
		refSources   map[string]*v1alpha1.RefTarget
		expectedPath string
		expectedErr  bool
	}{
		{
			name:         "simple path",
			rawPath:      "values.yaml",
			env:          &v1alpha1.Env{},
			refSources:   map[string]*v1alpha1.RefTarget{},
			expectedPath: path.Join(tempDir, "main-repo", "values.yaml"),
		},
		{
			name:    "simple ref",
			rawPath: "$ref/values.yaml",
			env:     &v1alpha1.Env{},
			refSources: map[string]*v1alpha1.RefTarget{
				"$ref": {
					Repo: v1alpha1.Repository{
						Repo: "https://github.com/org/repo1",
					},
				},
			},
			expectedPath: path.Join(tempDir, "repo1", "values.yaml"),
		},
		{
			name:    "only ref",
			rawPath: "$ref",
			env:     &v1alpha1.Env{},
			refSources: map[string]*v1alpha1.RefTarget{
				"$ref": {
					Repo: v1alpha1.Repository{
						Repo: "https://github.com/org/repo1",
					},
				},
			},
			expectedErr: true,
		},
		{
			name:    "attempted traversal",
			rawPath: "$ref/../values.yaml",
			env:     &v1alpha1.Env{},
			refSources: map[string]*v1alpha1.RefTarget{
				"$ref": {
					Repo: v1alpha1.Repository{
						Repo: "https://github.com/org/repo1",
					},
				},
			},
			expectedErr: true,
		},
		{
			// Since $ref doesn't resolve to a ref target, we assume it's an env var. Since the env var isn't specified,
			// it's replaced with an empty string. This is necessary for backwards compatibility with behavior before
			// ref targets were introduced.
			name:         "ref doesn't exist",
			rawPath:      "$ref/values.yaml",
			env:          &v1alpha1.Env{},
			refSources:   map[string]*v1alpha1.RefTarget{},
			expectedPath: path.Join(tempDir, "main-repo", "values.yaml"),
		},
		{
			name:    "repo doesn't exist",
			rawPath: "$ref/values.yaml",
			env:     &v1alpha1.Env{},
			refSources: map[string]*v1alpha1.RefTarget{
				"$ref": {
					Repo: v1alpha1.Repository{
						Repo: "https://github.com/org/repo2",
					},
				},
			},
			expectedErr: true,
		},
		{
			name:    "env var is resolved",
			rawPath: "$ref/$APP_PATH/values.yaml",
			env: &v1alpha1.Env{
				&v1alpha1.EnvEntry{
					Name:  "APP_PATH",
					Value: "app-path",
				},
			},
			refSources: map[string]*v1alpha1.RefTarget{
				"$ref": {
					Repo: v1alpha1.Repository{
						Repo: "https://github.com/org/repo1",
					},
				},
			},
			expectedPath: path.Join(tempDir, "repo1", "app-path", "values.yaml"),
		},
		{
			name:    "traversal in env var is blocked",
			rawPath: "$ref/$APP_PATH/values.yaml",
			env: &v1alpha1.Env{
				&v1alpha1.EnvEntry{
					Name:  "APP_PATH",
					Value: "..",
				},
			},
			refSources: map[string]*v1alpha1.RefTarget{
				"$ref": {
					Repo: v1alpha1.Repository{
						Repo: "https://github.com/org/repo1",
					},
				},
			},
			expectedErr: true,
		},
		{
			name:    "env var prefix",
			rawPath: "$APP_PATH/values.yaml",
			env: &v1alpha1.Env{
				&v1alpha1.EnvEntry{
					Name:  "APP_PATH",
					Value: "app-path",
				},
			},
			refSources:   map[string]*v1alpha1.RefTarget{},
			expectedPath: path.Join(tempDir, "main-repo", "app-path", "values.yaml"),
		},
		{
			name:         "unresolved env var",
			rawPath:      "$APP_PATH/values.yaml",
			env:          &v1alpha1.Env{},
			refSources:   map[string]*v1alpha1.RefTarget{},
			expectedPath: path.Join(tempDir, "main-repo", "values.yaml"),
		},
	}

	for _, tc := range testCases {
		tcc := tc
		t.Run(tcc.name, func(t *testing.T) {
			t.Parallel()
			resolvedPaths, err := getResolvedValueFiles(path.Join(tempDir, "main-repo"), path.Join(tempDir, "main-repo"), tcc.env, []string{}, []string{tcc.rawPath}, tcc.refSources, paths, false)
			if !tcc.expectedErr {
				require.NoError(t, err)
				require.Len(t, resolvedPaths, 1)
				assert.Equal(t, tcc.expectedPath, string(resolvedPaths[0]))
			} else {
				require.Error(t, err)
				assert.Empty(t, resolvedPaths)
			}
		})
	}
}

func Test_getResolvedValueFiles_glob(t *testing.T) {
	t.Parallel()

	tempDir := t.TempDir()
	paths := utilio.NewRandomizedTempPaths(tempDir)
	paths.Add(git.NormalizeGitURL("https://github.com/org/repo1"), path.Join(tempDir, "repo1"))

	// main-repo files
	require.NoError(t, os.MkdirAll(path.Join(tempDir, "main-repo", "prod", "nested"), 0o755))
	require.NoError(t, os.MkdirAll(path.Join(tempDir, "main-repo", "staging"), 0o755))
	require.NoError(t, os.WriteFile(path.Join(tempDir, "main-repo", "prod", "a.yaml"), []byte{}, 0o644))
	require.NoError(t, os.WriteFile(path.Join(tempDir, "main-repo", "prod", "b.yaml"), []byte{}, 0o644))
	require.NoError(t, os.WriteFile(path.Join(tempDir, "main-repo", "prod", "nested", "c.yaml"), []byte{}, 0o644))
	require.NoError(t, os.WriteFile(path.Join(tempDir, "main-repo", "prod", "nested", "d.yaml"), []byte{}, 0o644))
	require.NoError(t, os.WriteFile(path.Join(tempDir, "main-repo", "staging", "e.yaml"), []byte{}, 0o644))

	// main-repo envs: used to verify depth-order with ** (z.yaml sorts after nested/ alphabetically
	// but is still returned before nested/c.yaml because doublestar matches depth-0 files first).
	require.NoError(t, os.MkdirAll(path.Join(tempDir, "main-repo", "envs", "nested"), 0o755))
	require.NoError(t, os.WriteFile(path.Join(tempDir, "main-repo", "envs", "a.yaml"), []byte{}, 0o644))
	require.NoError(t, os.WriteFile(path.Join(tempDir, "main-repo", "envs", "z.yaml"), []byte{}, 0o644))
	require.NoError(t, os.WriteFile(path.Join(tempDir, "main-repo", "envs", "nested", "c.yaml"), []byte{}, 0o644))

	// repo1 files
	require.NoError(t, os.MkdirAll(path.Join(tempDir, "repo1", "prod", "nested"), 0o755))
	require.NoError(t, os.WriteFile(path.Join(tempDir, "repo1", "prod", "x.yaml"), []byte{}, 0o644))
	require.NoError(t, os.WriteFile(path.Join(tempDir, "repo1", "prod", "y.yaml"), []byte{}, 0o644))
	require.NoError(t, os.WriteFile(path.Join(tempDir, "repo1", "prod", "nested", "z.yaml"), []byte{}, 0o644))

	tests := []struct {
		name                    string
		rawPath                 string
		env                     *v1alpha1.Env
		refSources              map[string]*v1alpha1.RefTarget
		expectedPaths           []string
		ignoreMissingValueFiles bool
		expectedErr             bool
	}{
		{
			name:       "local glob matches multiple files",
			rawPath:    "prod/*.yaml",
			env:        &v1alpha1.Env{},
			refSources: map[string]*v1alpha1.RefTarget{},
			expectedPaths: []string{
				// the order is a.yaml before b.yaml
				// since doublestar.FilepathGlob returns lexical order
				path.Join(tempDir, "main-repo", "prod", "a.yaml"),
				path.Join(tempDir, "main-repo", "prod", "b.yaml"),
			},
		},
		{
			name:          "local glob matches no files returns error",
			rawPath:       "dev/*.yaml",
			env:           &v1alpha1.Env{},
			refSources:    map[string]*v1alpha1.RefTarget{},
			expectedPaths: nil,
			expectedErr:   true,
		},
		{
			name:                    "local glob matches no files with ignoreMissingValueFiles set to true",
			rawPath:                 "dev/*.yaml",
			env:                     &v1alpha1.Env{},
			refSources:              map[string]*v1alpha1.RefTarget{},
			ignoreMissingValueFiles: true,
			expectedPaths:           nil,
		},
		{
			name:    "referenced glob matches multiple files in external repo",
			rawPath: "$ref/prod/*.yaml",
			env:     &v1alpha1.Env{},
			refSources: map[string]*v1alpha1.RefTarget{
				"$ref": {
					Repo: v1alpha1.Repository{
						Repo: "https://github.com/org/repo1",
					},
				},
			},
			expectedPaths: []string{
				path.Join(tempDir, "repo1", "prod", "x.yaml"),
				path.Join(tempDir, "repo1", "prod", "y.yaml"),
			},
		},
		{
			name:    "ref glob with env var in path",
			rawPath: "$ref/$ENV/*.yaml",
			env: &v1alpha1.Env{
				&v1alpha1.EnvEntry{
					Name:  "ENV",
					Value: "prod",
				},
			},
			refSources: map[string]*v1alpha1.RefTarget{
				"$ref": {
					Repo: v1alpha1.Repository{
						Repo: "https://github.com/org/repo1",
					},
				},
			},
			expectedPaths: []string{
				path.Join(tempDir, "repo1", "prod", "x.yaml"),
				path.Join(tempDir, "repo1", "prod", "y.yaml"),
			},
		},
		{
			name:          "local glob single match",
			rawPath:       "prod/a*.yaml",
			env:           &v1alpha1.Env{},
			refSources:    map[string]*v1alpha1.RefTarget{},
			expectedPaths: []string{path.Join(tempDir, "main-repo", "prod", "a.yaml")},
		},
		{
			name: "recursive glob matches files at all depths under a subdirectory",
			// ** matches zero or more path segments, so prod/**/*.yaml covers both
			// prod/*.yaml (zero intermediate segments) and prod/nested/*.yaml (one segment), etc.
			rawPath:    "prod/**/*.yaml",
			env:        &v1alpha1.Env{},
			refSources: map[string]*v1alpha1.RefTarget{},
			// lexical order: prod/a.yaml, prod/b.yaml, prod/nested/c.yaml, prod/nested/d.yaml
			expectedPaths: []string{
				path.Join(tempDir, "main-repo", "prod", "a.yaml"),
				path.Join(tempDir, "main-repo", "prod", "b.yaml"),
				path.Join(tempDir, "main-repo", "prod", "nested", "c.yaml"),
				path.Join(tempDir, "main-repo", "prod", "nested", "d.yaml"),
			},
		},
		{
			name:       "recursive glob from repo root matches yaml files across all directories",
			rawPath:    "**/*.yaml",
			env:        &v1alpha1.Env{},
			refSources: map[string]*v1alpha1.RefTarget{},
			// doublestar traverses directories in lexical order, processing each directory's
			// own files before its subdirectories. So the order is:
			// envs/ flat files → envs/nested/ files → prod/ flat files → prod/nested/ files → staging/ files
			expectedPaths: []string{
				path.Join(tempDir, "main-repo", "envs", "a.yaml"),
				path.Join(tempDir, "main-repo", "envs", "z.yaml"),
				path.Join(tempDir, "main-repo", "envs", "nested", "c.yaml"),
				path.Join(tempDir, "main-repo", "prod", "a.yaml"),
				path.Join(tempDir, "main-repo", "prod", "b.yaml"),
				path.Join(tempDir, "main-repo", "prod", "nested", "c.yaml"),
				path.Join(tempDir, "main-repo", "prod", "nested", "d.yaml"),
				path.Join(tempDir, "main-repo", "staging", "e.yaml"),
			},
		},
		{
			name:       "recursive glob anchored to a named subdirectory matches at any depth",
			rawPath:    "**/nested/*.yaml",
			env:        &v1alpha1.Env{},
			refSources: map[string]*v1alpha1.RefTarget{},
			expectedPaths: []string{
				path.Join(tempDir, "main-repo", "envs", "nested", "c.yaml"),
				path.Join(tempDir, "main-repo", "prod", "nested", "c.yaml"),
				path.Join(tempDir, "main-repo", "prod", "nested", "d.yaml"),
			},
		},
		{
			name:                    "recursive glob with no matches and ignoreMissingValueFiles skips silently",
			rawPath:                 "**/nonexistent/*.yaml",
			env:                     &v1alpha1.Env{},
			refSources:              map[string]*v1alpha1.RefTarget{},
			ignoreMissingValueFiles: true,
			expectedPaths:           nil,
		},
		{
			name:          "recursive glob with no matches returns error",
			rawPath:       "**/nonexistent/*.yaml",
			env:           &v1alpha1.Env{},
			refSources:    map[string]*v1alpha1.RefTarget{},
			expectedPaths: nil,
			expectedErr:   true,
		},
		{
			// z.yaml sorts after "nested/" alphabetically by full path, but doublestar processes
			// each directory's own files before descending into subdirectories. So for envs/**/*.yaml:
			// envs/ flat files (a, z) come before envs/nested/ files (c), giving:
			// a.yaml, z.yaml, nested/c.yaml — not a.yaml, nested/c.yaml, z.yaml.
			name:       "** depth-order: flat files before nested even when flat file sorts after nested/ alphabetically",
			rawPath:    "envs/**/*.yaml",
			env:        &v1alpha1.Env{},
			refSources: map[string]*v1alpha1.RefTarget{},
			expectedPaths: []string{
				path.Join(tempDir, "main-repo", "envs", "a.yaml"),
				path.Join(tempDir, "main-repo", "envs", "z.yaml"),
				path.Join(tempDir, "main-repo", "envs", "nested", "c.yaml"),
			},
		},
		{
			name:    "recursive glob in external ref repo",
			rawPath: "$ref/prod/**/*.yaml",
			env:     &v1alpha1.Env{},
			refSources: map[string]*v1alpha1.RefTarget{
				"$ref": {
					Repo: v1alpha1.Repository{
						Repo: "https://github.com/org/repo1",
					},
				},
			},
			expectedPaths: []string{
				// doublestar matches zero path segments before recursing into subdirectories,
				// so flat files (x, y) come before nested ones (nested/z).
				path.Join(tempDir, "repo1", "prod", "x.yaml"),
				path.Join(tempDir, "repo1", "prod", "y.yaml"),
				path.Join(tempDir, "repo1", "prod", "nested", "z.yaml"),
			},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			repoPath := path.Join(tempDir, "main-repo")
			resolvedPaths, err := getResolvedValueFiles(repoPath, repoPath, tt.env, []string{}, []string{tt.rawPath}, tt.refSources, paths, tt.ignoreMissingValueFiles)
			if tt.expectedErr {
				require.Error(t, err)
				return
			}
			require.NoError(t, err)
			require.Len(t, resolvedPaths, len(tt.expectedPaths))

			for i, p := range tt.expectedPaths {
				assert.Equal(t, p, string(resolvedPaths[i]))
			}
		})
	}

	// Deduplication: first occurrence of a resolved path wins. Subsequent references to the
	// same file, whether explicit or via glob are silently dropped. This preserves the
	// merge-precedence position set by the first mention of each file.
	t.Run("glob then explicit: explicit entry placed at end, giving it highest Helm precedence", func(t *testing.T) {
		t.Parallel()
		repoPath := path.Join(tempDir, "main-repo")
		resolvedPaths, err := getResolvedValueFiles(
			repoPath, repoPath,
			&v1alpha1.Env{}, []string{},
			[]string{
				"envs/*.yaml", // glob - z.yaml is explicit so skipped; only a.yaml added
				"envs/z.yaml", // explicit - placed last, highest precedence
			},
			map[string]*v1alpha1.RefTarget{}, paths, false,
		)
		require.NoError(t, err)
		require.Len(t, resolvedPaths, 2)
		assert.Equal(t, path.Join(tempDir, "main-repo", "envs", "a.yaml"), string(resolvedPaths[0]))
		assert.Equal(t, path.Join(tempDir, "main-repo", "envs", "z.yaml"), string(resolvedPaths[1]))
	})

	t.Run("explicit path before glob: explicit position is kept, glob re-match is dropped", func(t *testing.T) {
		t.Parallel()
		repoPath := path.Join(tempDir, "main-repo")
		resolvedPaths, err := getResolvedValueFiles(
			repoPath, repoPath,
			&v1alpha1.Env{}, []string{},
			[]string{
				"prod/a.yaml", // explicit locks in position 0
				"prod/*.yaml", // glob - a.yaml already seen, only b.yaml is new
			},
			map[string]*v1alpha1.RefTarget{}, paths, false,
		)
		require.NoError(t, err)
		require.Len(t, resolvedPaths, 2)
		assert.Equal(t, path.Join(tempDir, "main-repo", "prod", "a.yaml"), string(resolvedPaths[0]))
		assert.Equal(t, path.Join(tempDir, "main-repo", "prod", "b.yaml"), string(resolvedPaths[1]))
	})

	t.Run("glob before explicit path: explicit position wins, glob skips the explicitly listed file", func(t *testing.T) {
		t.Parallel()
		repoPath := path.Join(tempDir, "main-repo")
		resolvedPaths, err := getResolvedValueFiles(
			repoPath, repoPath,
			&v1alpha1.Env{}, []string{},
			[]string{
				"prod/*.yaml", // glob - a.yaml is explicit so skipped; only b.yaml added (pos 0)
				"prod/a.yaml", // explicit - placed here at pos 1 (highest precedence)
			},
			map[string]*v1alpha1.RefTarget{}, paths, false,
		)
		require.NoError(t, err)
		require.Len(t, resolvedPaths, 2)
		assert.Equal(t, path.Join(tempDir, "main-repo", "prod", "b.yaml"), string(resolvedPaths[0]))
		assert.Equal(t, path.Join(tempDir, "main-repo", "prod", "a.yaml"), string(resolvedPaths[1]))
	})

	t.Run("two overlapping globs: second glob only adds files not matched by first", func(t *testing.T) {
		t.Parallel()
		repoPath := path.Join(tempDir, "main-repo")
		resolvedPaths, err := getResolvedValueFiles(
			repoPath, repoPath,
			&v1alpha1.Env{}, []string{},
			[]string{
				"prod/*.yaml",    // adds a.yaml, b.yaml
				"prod/**/*.yaml", // a.yaml, b.yaml already seen; adds nested/c.yaml, nested/d.yaml
			},
			map[string]*v1alpha1.RefTarget{}, paths, false,
		)
		require.NoError(t, err)
		require.Len(t, resolvedPaths, 4)
		assert.Equal(t, path.Join(tempDir, "main-repo", "prod", "a.yaml"), string(resolvedPaths[0]))
		assert.Equal(t, path.Join(tempDir, "main-repo", "prod", "b.yaml"), string(resolvedPaths[1]))
		assert.Equal(t, path.Join(tempDir, "main-repo", "prod", "nested", "c.yaml"), string(resolvedPaths[2]))
		assert.Equal(t, path.Join(tempDir, "main-repo", "prod", "nested", "d.yaml"), string(resolvedPaths[3]))
	})

	t.Run("explicit paths take priority: globs skip explicitly listed files, which are placed at their explicit positions", func(t *testing.T) {
		t.Parallel()
		repoPath := path.Join(tempDir, "main-repo")
		resolvedPaths, err := getResolvedValueFiles(
			repoPath, repoPath,
			&v1alpha1.Env{}, []string{},
			[]string{
				"prod/a.yaml",        // explicit - pos 0
				"prod/*.yaml",        // a.yaml and b.yaml are both explicit, skipped entirely
				"prod/b.yaml",        // explicit - pos 1
				"prod/**/*.yaml",     // a.yaml, b.yaml, nested/c.yaml all explicit and skipped; nested/d.yaml added - pos 2
				"prod/nested/c.yaml", // explicit - pos 3
			},
			map[string]*v1alpha1.RefTarget{}, paths, false,
		)
		require.NoError(t, err)
		require.Len(t, resolvedPaths, 4)
		assert.Equal(t, path.Join(tempDir, "main-repo", "prod", "a.yaml"), string(resolvedPaths[0]))
		assert.Equal(t, path.Join(tempDir, "main-repo", "prod", "b.yaml"), string(resolvedPaths[1]))
		assert.Equal(t, path.Join(tempDir, "main-repo", "prod", "nested", "d.yaml"), string(resolvedPaths[2]))
		assert.Equal(t, path.Join(tempDir, "main-repo", "prod", "nested", "c.yaml"), string(resolvedPaths[3]))
	})
}

func Test_verifyGlobMatchesWithinRoot(t *testing.T) {
	t.Parallel()

	tempDir := t.TempDir()
	repoDir := filepath.Join(tempDir, "repo")
	outsideDir := filepath.Join(tempDir, "outside")

	require.NoError(t, os.MkdirAll(filepath.Join(repoDir, "values", "sub"), 0o755))
	require.NoError(t, os.MkdirAll(outsideDir, 0o755))

	// Files used as symlink targets
	inRepoFile := filepath.Join(repoDir, "values", "real.yaml")
	outsideFile := filepath.Join(outsideDir, "secret.yaml")
	require.NoError(t, os.WriteFile(inRepoFile, []byte{}, 0o644))
	require.NoError(t, os.WriteFile(outsideFile, []byte("password: hunter2"), 0o644))

	// Symlink inside repo → file inside repo (safe)
	inRepoLink := filepath.Join(repoDir, "values", "inrepo-link.yaml")
	require.NoError(t, os.Symlink(inRepoFile, inRepoLink))

	// Symlink inside repo → file outside repo (escape)
	escapeLink := filepath.Join(repoDir, "values", "escape-link.yaml")
	require.NoError(t, os.Symlink(outsideFile, escapeLink))

	// Two-hop symlink: inside repo → another symlink (still inside) → file inside repo
	hop1 := filepath.Join(repoDir, "values", "hop1.yaml")
	require.NoError(t, os.Symlink(inRepoLink, hop1)) // hop1 → inRepoLink → real.yaml

	// Two-hop symlink: inside repo → another symlink (inside repo) → file outside repo
	hop2 := filepath.Join(repoDir, "values", "hop2.yaml")
	require.NoError(t, os.Symlink(escapeLink, hop2)) // hop2 → escape-link → secret.yaml

	tests := []struct {
		name        string
		matches     []string
		expectErr   bool
		errContains string
	}{
		{
			name:    "regular file inside root passes",
			matches: []string{inRepoFile},
		},
		{
			name:    "symlink inside root pointing to file inside root passes",
			matches: []string{inRepoLink},
		},
		{
			name:    "two-hop chain that stays within root passes",
			matches: []string{hop1},
		},
		{
			name:        "symlink pointing directly outside root is rejected",
			matches:     []string{escapeLink},
			expectErr:   true,
			errContains: "resolved to outside repository root",
		},
		{
			name:        "two-hop chain that escapes root is rejected",
			matches:     []string{hop2},
			expectErr:   true,
			errContains: "resolved to outside repository root",
		},
		{
			name:    "multiple matches all inside root pass",
			matches: []string{inRepoFile, inRepoLink, hop1},
		},
		{
			name:        "one bad match in a list fails the whole call",
			matches:     []string{inRepoFile, escapeLink},
			expectErr:   true,
			errContains: "resolved to outside repository root",
		},
		{
			name:    "empty matches list is a no-op",
			matches: []string{},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			err := verifyGlobMatchesWithinRoot(tt.matches, repoDir)
			if tt.expectErr {
				require.Error(t, err)
				assert.Contains(t, err.Error(), tt.errContains)
			} else {
				require.NoError(t, err)
			}
		})
	}
}

// Test_getResolvedValueFiles_glob_symlink_escape is an integration-level check
// that verifyGlobMatchesWithinRoot is wired into glob expansion correctly: a
// symlink inside the repo pointing outside must cause getResolvedValueFiles to
// return an error rather than silently including the external file.
func Test_getResolvedValueFiles_glob_symlink_escape(t *testing.T) {
	t.Parallel()

	tempDir := t.TempDir()
	paths := utilio.NewRandomizedTempPaths(tempDir)

	repoDir := filepath.Join(tempDir, "repo")
	outsideDir := filepath.Join(tempDir, "outside")

	require.NoError(t, os.MkdirAll(filepath.Join(repoDir, "values"), 0o755))
	require.NoError(t, os.MkdirAll(outsideDir, 0o755))

	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "values", "base.yaml"), []byte{}, 0o644))
	require.NoError(t, os.WriteFile(filepath.Join(outsideDir, "secret.yaml"), []byte("password: hunter2"), 0o644))
	require.NoError(t, os.Symlink(filepath.Join(outsideDir, "secret.yaml"), filepath.Join(repoDir, "values", "escape.yaml")))

	_, err := getResolvedValueFiles(repoDir, repoDir, &v1alpha1.Env{}, []string{}, []string{"values/*.yaml"}, map[string]*v1alpha1.RefTarget{}, paths, false)
	require.Error(t, err)
	assert.Contains(t, err.Error(), "resolved to outside repository root")
}

func Test_isGlobPath(t *testing.T) {
	tests := []struct {
		path     string
		expected bool
	}{
		{
			path:     "prod/*.yaml",
			expected: true,
		},
		{
			path:     "prod/?.yaml",
			expected: true,
		},
		{
			path:     "prod[ab].yaml",
			expected: true,
		},
		{
			path:     "prod/**/*.yaml",
			expected: true,
		},
		{
			path: "prod/values.yaml",
		},
		{
			path: "values.yaml",
		},
		{
			path: "",
		},
		{
			path:     "/absolute/path/to/*.yaml",
			expected: true,
		},
		{
			path: "/absolute/path/to/values.yaml",
		},
		{
			path:     "*",
			expected: true,
		},
		{
			path:     "?",
			expected: true,
		},
		{
			path:     "[",
			expected: true,
		},
	}
	for _, tt := range tests {
		t.Run(tt.path, func(t *testing.T) {
			assert.Equal(t, tt.expected, isGlobPath(tt.path))
		})
	}
}

func Test_getReferencedSource(t *testing.T) {
	t.Parallel()

	refTarget := &v1alpha1.RefTarget{
		Repo: v1alpha1.Repository{
			Repo: "https://github.com/org/repo1",
		},
	}
	tests := []struct {
		name         string
		rawValueFile string
		refSources   map[string]*v1alpha1.RefTarget
		expected     *v1alpha1.RefTarget
	}{
		{
			name:         "ref with file path found in map",
			rawValueFile: "$ref/values.yaml",
			refSources: map[string]*v1alpha1.RefTarget{
				"$ref": refTarget,
			},
			expected: refTarget,
		},
		{
			name:         "ref with file path not in map",
			rawValueFile: "$ref/values.yaml",
			refSources:   map[string]*v1alpha1.RefTarget{},
			expected:     nil,
		},
		{
			name:         "bare ref without file path found in map",
			rawValueFile: "$ref",
			refSources: map[string]*v1alpha1.RefTarget{
				"$ref": refTarget,
			},
			expected: refTarget,
		},
		{
			name:         "empty string returns nil",
			rawValueFile: "",
			refSources: map[string]*v1alpha1.RefTarget{
				"$ref": refTarget,
			},
			expected: nil,
		},
		{
			name:         "no $ prefix returns nil",
			rawValueFile: "values.yaml",
			refSources: map[string]*v1alpha1.RefTarget{
				"$ref": refTarget,
			},
			expected: nil,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			result := getReferencedSource(tt.rawValueFile, tt.refSources)
			assert.Equal(t, tt.expected, result)
		})
	}
}

func TestErrorGetGitDirectories(t *testing.T) {
	// test not using the cache
	root := "./testdata/git-files-dirs"

	type fields struct {
		service *Service
	}
	type args struct {
		ctx     context.Context
		request *apiclient.GitDirectoriesRequest
	}
	tests := []struct {
		name    string
		fields  fields
		args    args
		want    *apiclient.GitDirectoriesResponse
		wantErr assert.ErrorAssertionFunc
	}{
		{name: "InvalidRepo", fields: fields{service: newService(t, ".")}, args: args{
			ctx: t.Context(),
			request: &apiclient.GitDirectoriesRequest{
				Repo:             nil,
				SubmoduleEnabled: false,
				Revision:         "HEAD",
			},
		}, want: nil, wantErr: assert.Error},
		{name: "InvalidResolveRevision", fields: fields{service: func() *Service {
			s, _, _ := newServiceWithOpt(t, func(gitClient *gitmocks.Client, _ *helmmocks.Client, _ *ocimocks.Client, paths *iomocks.TempPaths) {
				gitClient.EXPECT().Checkout(mock.Anything, mock.Anything, mock.Anything).Return("", nil)
				gitClient.EXPECT().LsRemote(mock.Anything).Return("", errors.New("ah error"))
				gitClient.EXPECT().Root().Return(root)
				paths.EXPECT().GetPath(mock.Anything).Return(".", nil)
				paths.EXPECT().GetPathIfExists(mock.Anything).Return(".")
			}, ".")
			return s
		}()}, args: args{
			ctx: t.Context(),
			request: &apiclient.GitDirectoriesRequest{
				Repo:             &v1alpha1.Repository{Repo: "not-a-valid-url"},
				SubmoduleEnabled: false,
				Revision:         "sadfsadf",
			},
		}, want: nil, wantErr: assert.Error},
		{name: "ErrorVerifyCommit", fields: fields{service: func() *Service {
			s, _, _ := newServiceWithOpt(t, func(gitClient *gitmocks.Client, _ *helmmocks.Client, _ *ocimocks.Client, paths *iomocks.TempPaths) {
				gitClient.EXPECT().Checkout(mock.Anything, mock.Anything, mock.Anything).Return("", nil)
				gitClient.EXPECT().LsRemote(mock.Anything).Return("", errors.New("ah error"))
				gitClient.EXPECT().VerifyCommitSignature(mock.Anything).Return("", fmt.Errorf("revision %s is not signed", "sadfsadf"))
				gitClient.EXPECT().Root().Return(root)
				paths.EXPECT().GetPath(mock.Anything).Return(".", nil)
				paths.EXPECT().GetPathIfExists(mock.Anything).Return(".")
			}, ".")
			return s
		}()}, args: args{
			ctx: t.Context(),
			request: &apiclient.GitDirectoriesRequest{
				Repo:             &v1alpha1.Repository{Repo: "not-a-valid-url"},
				SubmoduleEnabled: false,
				Revision:         "sadfsadf",
				VerifyCommit:     true,
			},
		}, want: nil, wantErr: assert.Error},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			s := tt.fields.service
			got, err := s.GetGitDirectories(tt.args.ctx, tt.args.request)
			if !tt.wantErr(t, err, fmt.Sprintf("GetGitDirectories(%v, %v)", tt.args.ctx, tt.args.request)) {
				return
			}
			assert.Equalf(t, tt.want, got, "GetGitDirectories(%v, %v)", tt.args.ctx, tt.args.request)
		})
	}
}

func TestGetGitDirectories(t *testing.T) {
	// test not using the cache
	root := "./testdata/git-files-dirs"
	s, _, cacheMocks := newServiceWithOpt(t, func(gitClient *gitmocks.Client, _ *helmmocks.Client, _ *ocimocks.Client, paths *iomocks.TempPaths) {
		gitClient.EXPECT().Init().Return(nil)
		gitClient.EXPECT().IsRevisionPresent(mock.Anything).Return(false)
		gitClient.EXPECT().Fetch(mock.Anything, mock.Anything).Return(nil)
		gitClient.EXPECT().Checkout(mock.Anything, mock.Anything, mock.Anything).Once().Return("", nil)
		gitClient.EXPECT().LsRemote("HEAD").Return("632039659e542ed7de0c170a4fcc1c571b288fc0", nil)
		gitClient.EXPECT().Root().Return(root)
		paths.EXPECT().GetPath(mock.Anything).Return(root, nil)
		paths.EXPECT().GetPathIfExists(mock.Anything).Return(root)
	}, root)
	dirRequest := &apiclient.GitDirectoriesRequest{
		Repo:             &v1alpha1.Repository{Repo: "a-url.com"},
		SubmoduleEnabled: false,
		Revision:         "HEAD",
	}
	directories, err := s.GetGitDirectories(t.Context(), dirRequest)
	require.NoError(t, err)
	assert.ElementsMatch(t, directories.GetPaths(), []string{"app", "app/bar", "app/foo/bar", "somedir", "app/foo"})

	// do the same request again to use the cache
	// we only allow CheckOut to be called once in the mock
	directories, err = s.GetGitDirectories(t.Context(), dirRequest)
	require.NoError(t, err)
	assert.ElementsMatch(t, []string{"app", "app/bar", "app/foo/bar", "somedir", "app/foo"}, directories.GetPaths())
	cacheMocks.mockCache.AssertCacheCalledTimes(t, &repositorymocks.CacheCallCounts{
		ExternalSets: 1,
		ExternalGets: 2,
	})
}

func TestGetGitDirectoriesWithHiddenDirSupported(t *testing.T) {
	// test not using the cache
	root := "./testdata/git-files-dirs"
	s, _, cacheMocks := newServiceWithOpt(t, func(gitClient *gitmocks.Client, _ *helmmocks.Client, _ *ocimocks.Client, paths *iomocks.TempPaths) {
		gitClient.EXPECT().Init().Return(nil)
		gitClient.EXPECT().IsRevisionPresent(mock.Anything).Return(false)
		gitClient.EXPECT().Fetch(mock.Anything, mock.Anything).Return(nil)
		gitClient.EXPECT().Checkout(mock.Anything, mock.Anything, mock.Anything).Once().Return("", nil)
		gitClient.EXPECT().LsRemote("HEAD").Return("632039659e542ed7de0c170a4fcc1c571b288fc0", nil)
		gitClient.EXPECT().Root().Return(root)
		paths.EXPECT().GetPath(mock.Anything).Return(root, nil)
		paths.EXPECT().GetPathIfExists(mock.Anything).Return(root)
	}, root)
	s.initConstants.IncludeHiddenDirectories = true
	dirRequest := &apiclient.GitDirectoriesRequest{
		Repo:             &v1alpha1.Repository{Repo: "a-url.com"},
		SubmoduleEnabled: false,
		Revision:         "HEAD",
	}
	directories, err := s.GetGitDirectories(t.Context(), dirRequest)
	require.NoError(t, err)
	assert.ElementsMatch(t, directories.GetPaths(), []string{"app", "app/bar", "app/foo/bar", "somedir", "app/foo", "app/bar/.hidden"})

	// do the same request again to use the cache
	// we only allow CheckOut to be called once in the mock
	directories, err = s.GetGitDirectories(t.Context(), dirRequest)
	require.NoError(t, err)
	assert.ElementsMatch(t, []string{"app", "app/bar", "app/foo/bar", "somedir", "app/foo", "app/bar/.hidden"}, directories.GetPaths())
	cacheMocks.mockCache.AssertCacheCalledTimes(t, &repositorymocks.CacheCallCounts{
		ExternalSets: 1,
		ExternalGets: 2,
	})
}

func TestErrorGetGitFiles(t *testing.T) {
	// test not using the cache
	root := ""

	type fields struct {
		service *Service
	}
	type args struct {
		ctx     context.Context
		request *apiclient.GitFilesRequest
	}
	tests := []struct {
		name    string
		fields  fields
		args    args
		want    *apiclient.GitFilesResponse
		wantErr assert.ErrorAssertionFunc
	}{
		{name: "InvalidRepo", fields: fields{service: newService(t, ".")}, args: args{
			ctx: t.Context(),
			request: &apiclient.GitFilesRequest{
				Repo:             nil,
				SubmoduleEnabled: false,
				Revision:         "HEAD",
			},
		}, want: nil, wantErr: assert.Error},
		{name: "InvalidResolveRevision", fields: fields{service: func() *Service {
			s, _, _ := newServiceWithOpt(t, func(gitClient *gitmocks.Client, _ *helmmocks.Client, _ *ocimocks.Client, paths *iomocks.TempPaths) {
				gitClient.EXPECT().Checkout(mock.Anything, mock.Anything, mock.Anything).Return("", nil)
				gitClient.EXPECT().LsRemote(mock.Anything).Return("", errors.New("ah error"))
				gitClient.EXPECT().Root().Return(root)
				paths.EXPECT().GetPath(mock.Anything).Return(".", nil)
				paths.EXPECT().GetPathIfExists(mock.Anything).Return(".")
			}, ".")
			return s
		}()}, args: args{
			ctx: t.Context(),
			request: &apiclient.GitFilesRequest{
				Repo:             &v1alpha1.Repository{Repo: "not-a-valid-url"},
				SubmoduleEnabled: false,
				Revision:         "sadfsadf",
			},
		}, want: nil, wantErr: assert.Error},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			s := tt.fields.service
			got, err := s.GetGitFiles(tt.args.ctx, tt.args.request)
			if !tt.wantErr(t, err, fmt.Sprintf("GetGitFiles(%v, %v)", tt.args.ctx, tt.args.request)) {
				return
			}
			assert.Equalf(t, tt.want, got, "GetGitFiles(%v, %v)", tt.args.ctx, tt.args.request)
		})
	}
}

func TestGetGitFiles(t *testing.T) {
	// test not using the cache
	files := []string{
		"./testdata/git-files-dirs/somedir/config.yaml",
		"./testdata/git-files-dirs/config.yaml", "./testdata/git-files-dirs/config.yaml", "./testdata/git-files-dirs/app/foo/bar/config.yaml",
	}
	root := ""
	s, _, cacheMocks := newServiceWithOpt(t, func(gitClient *gitmocks.Client, _ *helmmocks.Client, _ *ocimocks.Client, paths *iomocks.TempPaths) {
		gitClient.EXPECT().Init().Return(nil)
		gitClient.EXPECT().IsRevisionPresent(mock.Anything).Return(false)
		gitClient.EXPECT().Fetch(mock.Anything, mock.Anything).Return(nil)
		gitClient.EXPECT().Checkout(mock.Anything, mock.Anything, mock.Anything).Once().Return("", nil)
		gitClient.EXPECT().LsRemote("HEAD").Return("632039659e542ed7de0c170a4fcc1c571b288fc0", nil)
		gitClient.EXPECT().Root().Return(root)
		gitClient.EXPECT().LsFiles(mock.Anything, mock.Anything).Once().Return(files, nil)
		paths.EXPECT().GetPath(mock.Anything).Return(root, nil)
		paths.EXPECT().GetPathIfExists(mock.Anything).Return(root)
	}, root)
	filesRequest := &apiclient.GitFilesRequest{
		Repo:             &v1alpha1.Repository{Repo: "a-url.com"},
		SubmoduleEnabled: false,
		Revision:         "HEAD",
	}

	// expected map
	expected := make(map[string][]byte)
	for _, filePath := range files {
		fileContents, err := os.ReadFile(filePath)
		require.NoError(t, err)
		expected[filePath] = fileContents
	}

	fileResponse, err := s.GetGitFiles(t.Context(), filesRequest)
	require.NoError(t, err)
	assert.Equal(t, expected, fileResponse.GetMap())

	// do the same request again to use the cache
	// we only allow LsFiles to be called once in the mock
	fileResponse, err = s.GetGitFiles(t.Context(), filesRequest)
	require.NoError(t, err)
	assert.Equal(t, expected, fileResponse.GetMap())
	cacheMocks.mockCache.AssertCacheCalledTimes(t, &repositorymocks.CacheCallCounts{
		ExternalSets: 1,
		ExternalGets: 2,
	})
}

func TestErrorUpdateRevisionForPaths(t *testing.T) {
	// test not using the cache
	root := ""

	type fields struct {
		service *Service
	}
	type args struct {
		ctx     context.Context
		request *apiclient.UpdateRevisionForPathsRequest
	}
	tests := []struct {
		name    string
		fields  fields
		args    args
		want    *apiclient.UpdateRevisionForPathsResponse
		wantErr assert.ErrorAssertionFunc
	}{
		{name: "InvalidRepo", fields: fields{service: newService(t, ".")}, args: args{
			ctx: t.Context(),
			request: &apiclient.UpdateRevisionForPathsRequest{
				Repo:           nil,
				Revision:       "HEAD",
				SyncedRevision: "sadfsadf",
				Paths:          []string{"."},
			},
		}, want: nil, wantErr: assert.Error},
		{name: "InvalidResolveRevision", fields: fields{service: func() *Service {
			s, _, _ := newServiceWithOpt(t, func(gitClient *gitmocks.Client, _ *helmmocks.Client, _ *ocimocks.Client, paths *iomocks.TempPaths) {
				gitClient.EXPECT().Checkout(mock.Anything, mock.Anything, mock.Anything).Return("", nil)
				gitClient.EXPECT().LsRemote(mock.Anything).Return("", errors.New("ah error"))
				gitClient.EXPECT().Root().Return(root)
				paths.EXPECT().GetPath(mock.Anything).Return(".", nil)
				paths.EXPECT().GetPathIfExists(mock.Anything).Return(".")
			}, ".")
			return s
		}()}, args: args{
			ctx: t.Context(),
			request: &apiclient.UpdateRevisionForPathsRequest{
				Repo:           &v1alpha1.Repository{Repo: "not-a-valid-url", Type: "git"},
				Revision:       "sadfsadf",
				SyncedRevision: "HEAD",
				Paths:          []string{"."},
			},
		}, want: nil, wantErr: assert.Error},
		{name: "InvalidResolveSyncedRevision", fields: fields{service: func() *Service {
			s, _, _ := newServiceWithOpt(t, func(gitClient *gitmocks.Client, _ *helmmocks.Client, _ *ocimocks.Client, paths *iomocks.TempPaths) {
				gitClient.EXPECT().Checkout(mock.Anything, mock.Anything, mock.Anything).Return("", nil)
				gitClient.EXPECT().LsRemote("HEAD").Once().Return("632039659e542ed7de0c170a4fcc1c571b288fc0", nil)
				gitClient.EXPECT().LsRemote(mock.Anything).Return("", errors.New("ah error"))
				gitClient.EXPECT().Root().Return(root)
				paths.EXPECT().GetPath(mock.Anything).Return(".", nil)
				paths.EXPECT().GetPathIfExists(mock.Anything).Return(".")
			}, ".")
			return s
		}()}, args: args{
			ctx: t.Context(),
			request: &apiclient.UpdateRevisionForPathsRequest{
				Repo:           &v1alpha1.Repository{Repo: "not-a-valid-url", Type: "git"},
				Revision:       "HEAD",
				SyncedRevision: "sadfsadf",
				Paths:          []string{"."},
			},
		}, want: nil, wantErr: assert.Error},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			s := tt.fields.service
			got, err := s.UpdateRevisionForPaths(tt.args.ctx, tt.args.request)
			if !tt.wantErr(t, err, fmt.Sprintf("UpdateRevisionForPaths(%v, %v)", tt.args.ctx, tt.args.request)) {
				return
			}
			assert.Equalf(t, tt.want, got, "UpdateRevisionForPaths(%v, %v)", tt.args.ctx, tt.args.request)
		})
	}
}

func TestUpdateRevisionForPaths(t *testing.T) {
	type fields struct {
		service *Service
		cache   *repoCacheMocks
	}
	type args struct {
		ctx     context.Context
		request *apiclient.UpdateRevisionForPathsRequest
	}
	type cacheHit struct {
		revision         string
		previousRevision string
	}
	tests := []struct {
		name           string
		fields         fields
		args           args
		want           *apiclient.UpdateRevisionForPathsResponse
		wantErr        assert.ErrorAssertionFunc
		cacheHit       *cacheHit
		cacheCallCount *repositorymocks.CacheCallCounts
	}{
		{name: "NoPathAbort", fields: func() fields {
			s, _, c := newServiceWithOpt(t, func(gitClient *gitmocks.Client, _ *helmmocks.Client, _ *ocimocks.Client, _ *iomocks.TempPaths) {
				gitClient.EXPECT().Checkout(mock.Anything, mock.Anything, mock.Anything).Return("", nil)
			}, ".")
			return fields{
				service: s,
				cache:   c,
			}
		}(), args: args{
			ctx: t.Context(),
			request: &apiclient.UpdateRevisionForPathsRequest{
				Repo:           &v1alpha1.Repository{Repo: "a-url.com", Type: "git"},
				Revision:       "",
				SyncedRevision: "",
				Paths:          []string{},
			},
		}, want: &apiclient.UpdateRevisionForPathsResponse{Changes: true}, wantErr: assert.NoError},
		{name: "SameResolvedRevisionAbort", fields: func() fields {
			s, _, c := newServiceWithOpt(t, func(gitClient *gitmocks.Client, _ *helmmocks.Client, _ *ocimocks.Client, paths *iomocks.TempPaths) {
				gitClient.EXPECT().Checkout(mock.Anything, mock.Anything, mock.Anything).Return("", nil)
				gitClient.EXPECT().LsRemote("HEAD").Once().Return("632039659e542ed7de0c170a4fcc1c571b288fc0", nil)
				gitClient.EXPECT().LsRemote("SYNCEDHEAD").Once().Return("632039659e542ed7de0c170a4fcc1c571b288fc0", nil)
				paths.EXPECT().GetPath(mock.Anything).Return(".", nil)
				paths.EXPECT().GetPathIfExists(mock.Anything).Return(".")
			}, ".")
			return fields{
				service: s,
				cache:   c,
			}
		}(), args: args{
			ctx: t.Context(),
			request: &apiclient.UpdateRevisionForPathsRequest{
				Repo:           &v1alpha1.Repository{Repo: "a-url.com", Type: "git"},
				Revision:       "HEAD",
				SyncedRevision: "SYNCEDHEAD",
				Paths:          []string{"."},
			},
		}, want: &apiclient.UpdateRevisionForPathsResponse{
			Revision: "632039659e542ed7de0c170a4fcc1c571b288fc0",
		}, wantErr: assert.NoError, cacheCallCount: &repositorymocks.CacheCallCounts{
			ExternalRenames: 0,
			ExternalGets:    0,
			ExternalSets:    0,
		}},
		{name: "ChangedFilesDoNothing", fields: func() fields {
			s, _, c := newServiceWithOpt(t, func(gitClient *gitmocks.Client, _ *helmmocks.Client, _ *ocimocks.Client, paths *iomocks.TempPaths) {
				gitClient.EXPECT().Init().Return(nil)
				gitClient.EXPECT().Fetch(mock.Anything, mock.Anything).Once().Return(nil)
				gitClient.EXPECT().IsRevisionPresent("632039659e542ed7de0c170a4fcc1c571b288fc0").Once().Return(false)
				gitClient.EXPECT().Checkout("632039659e542ed7de0c170a4fcc1c571b288fc0", mock.Anything, mock.Anything).Once().Return("", nil)
				// fetch
				gitClient.EXPECT().IsRevisionPresent("1e67a504d03def3a6a1125d934cb511680f72555").Once().Return(false)
				gitClient.EXPECT().Fetch(mock.Anything, mock.Anything).Once().Return(nil)
				gitClient.EXPECT().IsRevisionPresent("1e67a504d03def3a6a1125d934cb511680f72555").Once().Return(true)
				gitClient.EXPECT().LsRemote("HEAD").Once().Return("632039659e542ed7de0c170a4fcc1c571b288fc0", nil)
				gitClient.EXPECT().LsRemote("SYNCEDHEAD").Once().Return("1e67a504d03def3a6a1125d934cb511680f72555", nil)
				paths.EXPECT().GetPath(mock.Anything).Return(".", nil)
				paths.EXPECT().GetPathIfExists(mock.Anything).Return(".")
				gitClient.EXPECT().Root().Return("")
				gitClient.EXPECT().ChangedFiles(mock.Anything, mock.Anything).Return([]string{"app.yaml"}, nil)
			}, ".")
			return fields{
				service: s,
				cache:   c,
			}
		}(), args: args{
			ctx: t.Context(),
			request: &apiclient.UpdateRevisionForPathsRequest{
				Repo:           &v1alpha1.Repository{Repo: "a-url.com", Type: "git"},
				Revision:       "HEAD",
				SyncedRevision: "SYNCEDHEAD",
				Paths:          []string{"."},
			},
		}, want: &apiclient.UpdateRevisionForPathsResponse{
			Revision: "632039659e542ed7de0c170a4fcc1c571b288fc0",
			Changes:  true,
		}, wantErr: assert.NoError, cacheCallCount: &repositorymocks.CacheCallCounts{
			ExternalRenames: 0,
			ExternalGets:    1,
			ExternalSets:    1,
		}},
		{name: "NoChangesUpdateCache", fields: func() fields {
			s, _, c := newServiceWithOpt(t, func(gitClient *gitmocks.Client, _ *helmmocks.Client, _ *ocimocks.Client, paths *iomocks.TempPaths) {
				gitClient.EXPECT().Init().Return(nil)
				gitClient.EXPECT().Fetch(mock.Anything, mock.Anything).Once().Return(nil)
				gitClient.EXPECT().IsRevisionPresent("632039659e542ed7de0c170a4fcc1c571b288fc0").Once().Return(false)
				gitClient.EXPECT().Checkout(mock.Anything, mock.Anything, mock.Anything).Return("", nil)
				gitClient.EXPECT().IsRevisionPresent("1e67a504d03def3a6a1125d934cb511680f72555").Once().Return(false)
				// fetch
				gitClient.EXPECT().Fetch(mock.Anything, mock.Anything).Once().Return(nil)
				gitClient.EXPECT().IsRevisionPresent("1e67a504d03def3a6a1125d934cb511680f72555").Once().Return(true)
				gitClient.EXPECT().LsRemote("HEAD").Once().Return("632039659e542ed7de0c170a4fcc1c571b288fc0", nil)
				gitClient.EXPECT().LsRemote("SYNCEDHEAD").Once().Return("1e67a504d03def3a6a1125d934cb511680f72555", nil)
				paths.EXPECT().GetPath(mock.Anything).Return(".", nil)
				paths.EXPECT().GetPathIfExists(mock.Anything).Return(".")
				gitClient.EXPECT().Root().Return("")
				gitClient.EXPECT().ChangedFiles(mock.Anything, mock.Anything).Return([]string{}, nil)
			}, ".")
			return fields{
				service: s,
				cache:   c,
			}
		}(), args: args{
			ctx: t.Context(),
			request: &apiclient.UpdateRevisionForPathsRequest{
				Repo:              &v1alpha1.Repository{Repo: "a-url.com", Type: "git"},
				Revision:          "HEAD",
				SyncedRevision:    "SYNCEDHEAD",
				Paths:             []string{"."},
				AppLabelKey:       "app.kubernetes.io/name",
				AppName:           "no-change-update-cache",
				Namespace:         "default",
				TrackingMethod:    "annotation+label",
				ApplicationSource: &v1alpha1.ApplicationSource{Path: "."},
				KubeVersion:       "v1.16.0",
			},
		}, want: &apiclient.UpdateRevisionForPathsResponse{
			Revision: "632039659e542ed7de0c170a4fcc1c571b288fc0", Changes: true, // FIXME: need to fix changes=true, because now test can't mock Rename cache

		}, wantErr: assert.NoError, cacheHit: &cacheHit{
			previousRevision: "1e67a504d03def3a6a1125d934cb511680f72555",
			revision:         "632039659e542ed7de0c170a4fcc1c571b288fc0",
		}, cacheCallCount: &repositorymocks.CacheCallCounts{
			ExternalRenames: 1,
			ExternalGets:    1,
			ExternalSets:    1,
		}},
		{name: "NoChangesHelmMultiSourceUpdateCache", fields: func() fields {
			s, _, c := newServiceWithOpt(t, func(gitClient *gitmocks.Client, _ *helmmocks.Client, _ *ocimocks.Client, paths *iomocks.TempPaths) {
				gitClient.EXPECT().Init().Return(nil)
				gitClient.EXPECT().IsRevisionPresent("632039659e542ed7de0c170a4fcc1c571b288fc0").Once().Return(false)
				gitClient.EXPECT().Fetch(mock.Anything, mock.Anything).Once().Return(nil)
				gitClient.EXPECT().Checkout(mock.Anything, mock.Anything, mock.Anything).Return("", nil)
				// fetch
				gitClient.EXPECT().IsRevisionPresent("1e67a504d03def3a6a1125d934cb511680f72555").Once().Return(true)
				gitClient.EXPECT().Fetch(mock.Anything, mock.Anything).Once().Return(nil)
				gitClient.EXPECT().LsRemote("HEAD").Once().Return("632039659e542ed7de0c170a4fcc1c571b288fc0", nil)
				gitClient.EXPECT().LsRemote("SYNCEDHEAD").Once().Return("1e67a504d03def3a6a1125d934cb511680f72555", nil)
				paths.EXPECT().GetPath(mock.Anything).Return(".", nil)
				paths.EXPECT().GetPathIfExists(mock.Anything).Return(".")
				gitClient.EXPECT().Root().Return("")
				gitClient.EXPECT().ChangedFiles(mock.Anything, mock.Anything).Return([]string{}, nil)
			}, ".")
			return fields{
				service: s,
				cache:   c,
			}
		}(), args: args{
			ctx: t.Context(),
			request: &apiclient.UpdateRevisionForPathsRequest{
				Repo:              &v1alpha1.Repository{Repo: "a-url.com", Type: "git"},
				Revision:          "HEAD",
				SyncedRevision:    "SYNCEDHEAD",
				Paths:             []string{"."},
				AppLabelKey:       "app.kubernetes.io/name",
				AppName:           "no-change-update-cache",
				Namespace:         "default",
				TrackingMethod:    "annotation+label",
				ApplicationSource: &v1alpha1.ApplicationSource{Path: ".", Helm: &v1alpha1.ApplicationSourceHelm{ReleaseName: "test"}},
				KubeVersion:       "v1.16.0",

				HasMultipleSources: true,
			},
		}, want: &apiclient.UpdateRevisionForPathsResponse{
			Revision: "632039659e542ed7de0c170a4fcc1c571b288fc0", Changes: true, // FIXME: need to fix changes=true, because now test can't mock Rename cache
		}, wantErr: assert.NoError, cacheHit: &cacheHit{
			previousRevision: "1e67a504d03def3a6a1125d934cb511680f72555",
			revision:         "632039659e542ed7de0c170a4fcc1c571b288fc0",
		}, cacheCallCount: &repositorymocks.CacheCallCounts{
			ExternalRenames: 1,
			ExternalGets:    1,
			ExternalSets:    1,
		}},
		{name: "NoChangesHelmWithRefMultiSourceUpdateCache", fields: func() fields {
			s, _, c := newServiceWithOpt(t, func(gitClient *gitmocks.Client, _ *helmmocks.Client, _ *ocimocks.Client, paths *iomocks.TempPaths) {
				gitClient.EXPECT().Init().Return(nil)
				gitClient.EXPECT().IsRevisionPresent("632039659e542ed7de0c170a4fcc1c571b288fc0").Once().Return(false)
				gitClient.EXPECT().Fetch(mock.Anything, mock.Anything).Once().Return(nil)
				gitClient.EXPECT().Checkout(mock.Anything, mock.Anything, mock.Anything).Return("", nil)
				// fetch
				gitClient.EXPECT().IsRevisionPresent("1e67a504d03def3a6a1125d934cb511680f72555").Once().Return(true)
				gitClient.EXPECT().IsRevisionPresent("732039659e542ed7de0c170a4fcc1c571b288fc1").Once().Return(true)
				gitClient.EXPECT().IsRevisionPresent("2e67a504d03def3a6a1125d934cb511680f72554").Once().Return(true)
				gitClient.EXPECT().Fetch(mock.Anything, mock.Anything).Once().Return(nil)
				gitClient.EXPECT().LsRemote("HEAD").Once().Return("632039659e542ed7de0c170a4fcc1c571b288fc0", nil)
				gitClient.EXPECT().LsRemote("SYNCEDHEAD").Once().Return("1e67a504d03def3a6a1125d934cb511680f72555", nil)
				gitClient.EXPECT().LsRemote("HEAD-1").Once().Return("732039659e542ed7de0c170a4fcc1c571b288fc1", nil)
				gitClient.EXPECT().LsRemote("SYNCEDHEAD-1").Once().Return("2e67a504d03def3a6a1125d934cb511680f72554", nil)
				paths.EXPECT().GetPath(mock.Anything).Return(".", nil)
				paths.EXPECT().GetPathIfExists(mock.Anything).Return(".")
				gitClient.EXPECT().Root().Return("")
				gitClient.EXPECT().ChangedFiles(mock.Anything, mock.Anything).Return([]string{}, nil)
			}, ".")
			return fields{
				service: s,
				cache:   c,
			}
		}(), args: args{
			ctx: t.Context(),
			request: &apiclient.UpdateRevisionForPathsRequest{
				Repo: &v1alpha1.Repository{Repo: "url.com", Type: "helm"},
				RefSources: v1alpha1.RefTargetRevisionMapping{
					"$values":   {Repo: v1alpha1.Repository{Repo: "a-url.com"}, Chart: "test", TargetRevision: "HEAD"},
					"$values_2": {Repo: v1alpha1.Repository{Repo: "a-url.com"}, Chart: "test", TargetRevision: "HEAD-1"},
				},
				SyncedRefSources: v1alpha1.RefTargetRevisionMapping{
					"$values":   {Repo: v1alpha1.Repository{Repo: "a-url.com"}, Chart: "test", TargetRevision: "SYNCEDHEAD"},
					"$values_2": {Repo: v1alpha1.Repository{Repo: "a-url.com"}, Chart: "test", TargetRevision: "SYNCEDHEAD-1"},
				},
				Revision:           "0.0.1",
				SyncedRevision:     "0.0.1",
				Paths:              []string{"."},
				AppLabelKey:        "app.kubernetes.io/name",
				AppName:            "no-change-update-cache",
				Namespace:          "default",
				TrackingMethod:     "annotation+label",
				ApplicationSource:  &v1alpha1.ApplicationSource{Path: ".", Helm: &v1alpha1.ApplicationSourceHelm{ReleaseName: "test", ValueFiles: []string{"$values/path", "$values_2/path"}}},
				KubeVersion:        "v1.16.0",
				HasMultipleSources: true,
			},
		}, want: &apiclient.UpdateRevisionForPathsResponse{
			Revision: "0.0.1", Changes: true, // FIXME: need to fix changes=true, because now test can't mock Rename cache
		}, wantErr: assert.NoError, cacheHit: &cacheHit{
			previousRevision: "0.0.1",
			revision:         "0.0.1",
		}, cacheCallCount: &repositorymocks.CacheCallCounts{
			ExternalRenames: 1,
			ExternalGets:    2,
			ExternalSets:    2,
		}},
		{name: "NoChangesHelmWithRefMultiSource_IgnoreUnusedRef", fields: func() fields {
			s, _, c := newServiceWithOpt(t, func(gitClient *gitmocks.Client, _ *helmmocks.Client, _ *ocimocks.Client, paths *iomocks.TempPaths) {
				gitClient.EXPECT().Init().Return(nil)
				gitClient.EXPECT().IsRevisionPresent("632039659e542ed7de0c170a4fcc1c571b288fc0").Once().Return(false)
				gitClient.EXPECT().Fetch(mock.Anything, mock.Anything).Once().Return(nil)
				gitClient.EXPECT().Checkout(mock.Anything, mock.Anything, mock.Anything).Return("", nil)
				// fetch
				gitClient.EXPECT().IsRevisionPresent("1e67a504d03def3a6a1125d934cb511680f72555").Once().Return(true)
				gitClient.EXPECT().IsRevisionPresent("732039659e542ed7de0c170a4fcc1c571b288fc1").Once().Return(true)
				gitClient.EXPECT().IsRevisionPresent("2e67a504d03def3a6a1125d934cb511680f72554").Once().Return(true)
				gitClient.EXPECT().Fetch(mock.Anything, mock.Anything).Once().Return(nil)
				gitClient.EXPECT().LsRemote("HEAD").Once().Return("632039659e542ed7de0c170a4fcc1c571b288fc0", nil)
				gitClient.EXPECT().LsRemote("SYNCEDHEAD").Once().Return("1e67a504d03def3a6a1125d934cb511680f72555", nil)
				paths.EXPECT().GetPath(mock.Anything).Return(".", nil)
				paths.EXPECT().GetPathIfExists(mock.Anything).Return(".")
				gitClient.EXPECT().Root().Return("")
				gitClient.EXPECT().ChangedFiles(mock.Anything, mock.Anything).Return([]string{}, nil)
			}, ".")
			return fields{
				service: s,
				cache:   c,
			}
		}(), args: args{
			ctx: t.Context(),
			request: &apiclient.UpdateRevisionForPathsRequest{
				Repo: &v1alpha1.Repository{Repo: "url.com", Type: "helm"},
				RefSources: v1alpha1.RefTargetRevisionMapping{
					"$values":   {Repo: v1alpha1.Repository{Repo: "a-url.com"}, Chart: "test", TargetRevision: "HEAD"},
					"$values_2": {Repo: v1alpha1.Repository{Repo: "a-url.com"}, Chart: "test", TargetRevision: "HEAD-1"},
				},
				SyncedRefSources: v1alpha1.RefTargetRevisionMapping{
					"$values":   {Repo: v1alpha1.Repository{Repo: "a-url.com"}, Chart: "test", TargetRevision: "SYNCEDHEAD"},
					"$values_2": {Repo: v1alpha1.Repository{Repo: "a-url.com"}, Chart: "test", TargetRevision: "SYNCEDHEAD-1"},
				},
				Revision:           "0.0.1",
				SyncedRevision:     "0.0.1",
				Paths:              []string{"."},
				AppLabelKey:        "app.kubernetes.io/name",
				AppName:            "no-change-update-cache",
				Namespace:          "default",
				TrackingMethod:     "annotation+label",
				ApplicationSource:  &v1alpha1.ApplicationSource{Path: ".", Helm: &v1alpha1.ApplicationSourceHelm{ReleaseName: "test", ValueFiles: []string{"$values/path"}}},
				KubeVersion:        "v1.16.0",
				HasMultipleSources: true,
			},
		}, want: &apiclient.UpdateRevisionForPathsResponse{
			Revision: "0.0.1", Changes: true, // FIXME: need to fix changes=true, because now test can't mock Rename cache
		}, wantErr: assert.NoError, cacheHit: &cacheHit{
			previousRevision: "0.0.1",
			revision:         "0.0.1",
		}, cacheCallCount: &repositorymocks.CacheCallCounts{
			ExternalRenames: 1,
			ExternalGets:    1,
			ExternalSets:    1,
		}},
		{name: "NoChangesHelmWithRefMultiSource_UndefinedRef", fields: func() fields {
			s, _, c := newServiceWithOpt(t, func(gitClient *gitmocks.Client, _ *helmmocks.Client, _ *ocimocks.Client, paths *iomocks.TempPaths) {
				gitClient.EXPECT().Init().Return(nil)
				gitClient.EXPECT().IsRevisionPresent("632039659e542ed7de0c170a4fcc1c571b288fc0").Once().Return(false)
				gitClient.EXPECT().Fetch(mock.Anything, mock.Anything).Once().Return(nil)
				gitClient.EXPECT().Checkout(mock.Anything, mock.Anything, mock.Anything).Return("", nil)
				// fetch
				gitClient.EXPECT().IsRevisionPresent("1e67a504d03def3a6a1125d934cb511680f72555").Once().Return(true)
				gitClient.EXPECT().IsRevisionPresent("732039659e542ed7de0c170a4fcc1c571b288fc1").Once().Return(true)
				gitClient.EXPECT().IsRevisionPresent("2e67a504d03def3a6a1125d934cb511680f72554").Once().Return(true)
				gitClient.EXPECT().Fetch(mock.Anything, mock.Anything).Once().Return(nil)
				gitClient.EXPECT().LsRemote("HEAD").Once().Return("632039659e542ed7de0c170a4fcc1c571b288fc0", nil)
				gitClient.EXPECT().LsRemote("SYNCEDHEAD").Once().Return("1e67a504d03def3a6a1125d934cb511680f72555", nil)
				paths.EXPECT().GetPath(mock.Anything).Return(".", nil)
				paths.EXPECT().GetPathIfExists(mock.Anything).Return(".")
				gitClient.EXPECT().Root().Return("")
				gitClient.EXPECT().ChangedFiles(mock.Anything, mock.Anything).Return([]string{}, nil)
			}, ".")
			return fields{
				service: s,
				cache:   c,
			}
		}(), args: args{
			ctx: t.Context(),
			request: &apiclient.UpdateRevisionForPathsRequest{
				Repo: &v1alpha1.Repository{Repo: "url.com", Type: "helm"},
				RefSources: v1alpha1.RefTargetRevisionMapping{
					"$values":   {Repo: v1alpha1.Repository{Repo: "a-url.com"}, Chart: "test", TargetRevision: "HEAD"},
					"$values_2": {Repo: v1alpha1.Repository{Repo: "a-url.com"}, Chart: "test", TargetRevision: "HEAD-1"},
				},
				SyncedRefSources: v1alpha1.RefTargetRevisionMapping{
					"$values":   {Repo: v1alpha1.Repository{Repo: "a-url.com"}, Chart: "test", TargetRevision: "SYNCEDHEAD"},
					"$values_2": {Repo: v1alpha1.Repository{Repo: "a-url.com"}, Chart: "test", TargetRevision: "SYNCEDHEAD-1"},
				},
				Revision:           "0.0.1",
				SyncedRevision:     "0.0.1",
				Paths:              []string{"."},
				AppLabelKey:        "app.kubernetes.io/name",
				AppName:            "no-change-update-cache",
				Namespace:          "default",
				TrackingMethod:     "annotation+label",
				ApplicationSource:  &v1alpha1.ApplicationSource{Path: ".", Helm: &v1alpha1.ApplicationSourceHelm{ReleaseName: "test", ValueFiles: []string{"$values_3/path"}}},
				KubeVersion:        "v1.16.0",
				HasMultipleSources: true,
			},
		}, want: &apiclient.UpdateRevisionForPathsResponse{
			Revision: "0.0.1", Changes: true,
		}, wantErr: assert.Error, cacheHit: nil},
		{name: "IgnoreRefSourcesForGitSource", fields: func() fields {
			s, _, c := newServiceWithOpt(t, func(gitClient *gitmocks.Client, _ *helmmocks.Client, _ *ocimocks.Client, paths *iomocks.TempPaths) {
				gitClient.EXPECT().Init().Return(nil)
				gitClient.EXPECT().IsRevisionPresent("632039659e542ed7de0c170a4fcc1c571b288fc0").Once().Return(false)
				gitClient.EXPECT().Fetch(mock.Anything, mock.Anything).Once().Return(nil)
				gitClient.EXPECT().Checkout(mock.Anything, mock.Anything, mock.Anything).Return("", nil)
				// fetch
				gitClient.EXPECT().IsRevisionPresent("1e67a504d03def3a6a1125d934cb511680f72555").Once().Return(true)
				gitClient.EXPECT().IsRevisionPresent("732039659e542ed7de0c170a4fcc1c571b288fc1").Once().Return(true)
				gitClient.EXPECT().IsRevisionPresent("2e67a504d03def3a6a1125d934cb511680f72554").Once().Return(true)
				gitClient.EXPECT().Fetch(mock.Anything, mock.Anything).Once().Return(nil)
				gitClient.EXPECT().LsRemote("HEAD").Once().Return("632039659e542ed7de0c170a4fcc1c571b288fc0", nil)
				gitClient.EXPECT().LsRemote("SYNCEDHEAD").Once().Return("1e67a504d03def3a6a1125d934cb511680f72555", nil)
				paths.EXPECT().GetPath(mock.Anything).Return(".", nil)
				paths.EXPECT().GetPathIfExists(mock.Anything).Return(".")
				gitClient.EXPECT().Root().Return("")
				gitClient.EXPECT().ChangedFiles(mock.Anything, mock.Anything).Return([]string{}, nil)
			}, ".")
			return fields{
				service: s,
				cache:   c,
			}
		}(), args: args{
			ctx: t.Context(),
			request: &apiclient.UpdateRevisionForPathsRequest{
				Repo: &v1alpha1.Repository{Repo: "https://github.com", Type: "git"},
				RefSources: v1alpha1.RefTargetRevisionMapping{
					"$values_2": {Repo: v1alpha1.Repository{Repo: "a-url.com"}, Chart: "test", TargetRevision: "HEAD-1"},
				},
				SyncedRefSources: v1alpha1.RefTargetRevisionMapping{
					"$values_2": {Repo: v1alpha1.Repository{Repo: "a-url.com"}, Chart: "test", TargetRevision: "SYNCEDHEAD-1"},
				},
				Revision:       "HEAD",
				SyncedRevision: "SYNCEDHEAD",
				Paths:          []string{"."},
				AppLabelKey:    "app.kubernetes.io/name",
				AppName:        "no-change-update-cache",
				Namespace:      "default",
				TrackingMethod: "annotation+label",
				ApplicationSource: &v1alpha1.ApplicationSource{
					Path:           ".",
					Helm:           &v1alpha1.ApplicationSourceHelm{ReleaseName: "test", ValueFiles: []string{"path"}},
					RepoURL:        "https://github.com",
					TargetRevision: "HEAD",
				},
				KubeVersion:        "v1.16.0",
				HasMultipleSources: true,
			},
		}, want: &apiclient.UpdateRevisionForPathsResponse{
			Revision: "632039659e542ed7de0c170a4fcc1c571b288fc0", Changes: true, // FIXME: need to fix changes=true, because now test can't mock Rename cache
		}, wantErr: assert.NoError, cacheHit: &cacheHit{
			previousRevision: "632039659e542ed7de0c170a4fcc1c571b288fc0",
			revision:         "1e67a504d03def3a6a1125d934cb511680f72555",
		}, cacheCallCount: &repositorymocks.CacheCallCounts{
			ExternalRenames: 1,
			ExternalGets:    1,
			ExternalSets:    1,
		}},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			s := tt.fields.service
			cache := tt.fields.cache

			if tt.cacheHit != nil {
				cache.mockCache.On("Rename", tt.cacheHit.previousRevision, tt.cacheHit.revision, mock.Anything).Return(nil)
			}

			got, err := s.UpdateRevisionForPaths(tt.args.ctx, tt.args.request)
			if !tt.wantErr(t, err, fmt.Sprintf("UpdateRevisionForPaths(%v, %v)", tt.args.ctx, tt.args.request)) {
				return
			}
			assert.Equalf(t, tt.want, got, "UpdateRevisionForPaths(%v, %v)", tt.args.ctx, tt.args.request)

			if tt.cacheCallCount != nil {
				cache.mockCache.AssertCacheCalledTimes(t, tt.cacheCallCount)
			}
		})
	}
}

func Test_getRepoSanitizerRegex(t *testing.T) {
	r := getRepoSanitizerRegex("/tmp/_argocd-repo")
	msg := r.ReplaceAllString("error message containing /tmp/_argocd-repo/SENSITIVE and other stuff", "<path to cached source>")
	assert.Equal(t, "error message containing <path to cached source> and other stuff", msg)
	msg = r.ReplaceAllString("error message containing /tmp/_argocd-repo/SENSITIVE/with/trailing/path and other stuff", "<path to cached source>")
	assert.Equal(t, "error message containing <path to cached source>/with/trailing/path and other stuff", msg)
}

func TestGetRefs_CacheWithLockDisabled(t *testing.T) {
	// Test that when the lock is disabled the default behavior still works correctly
	// Also shows the current issue with the git requests due to cache misses
	dir := t.TempDir()
	initGitRepo(t, newGitRepoOptions{
		path:           dir,
		createPath:     false,
		remote:         "",
		addEmptyCommit: true,
	})
	// Test in-memory and redis
	cacheMocks := newCacheMocksWithOpts(1*time.Minute, 1*time.Minute, 0)
	t.Cleanup(cacheMocks.mockCache.StopRedisCallback)
	var wg sync.WaitGroup
	numberOfCallers := 10
	for range numberOfCallers {
		wg.Go(func() {
			client, err := git.NewClient("file://"+dir, git.NopCreds{}, true, false, "", "", git.WithCache(cacheMocks.cache, true))
			require.NoError(t, err)
			refs, err := client.LsRefs()
			require.NoError(t, err)
			assert.NotNil(t, refs)
			assert.NotEmpty(t, refs.Branches, "Expected branches to be populated")
			assert.NotEmpty(t, refs.Branches[0])
		})
	}
	wg.Wait()
	// Unlock should not have been called
	cacheMocks.mockCache.AssertNumberOfCalls(t, "UnlockGitReferences", 0)
	// Lock should not have been called
	cacheMocks.mockCache.AssertNumberOfCalls(t, "TryLockGitRefCache", 0)
}

func TestGetRefs_CacheDisabled(t *testing.T) {
	// Test that default get refs with cache disabled does not call GetOrLockGitReferences
	dir := t.TempDir()
	initGitRepo(t, newGitRepoOptions{
		path:           dir,
		createPath:     false,
		remote:         "",
		addEmptyCommit: true,
	})
	cacheMocks := newCacheMocks()
	t.Cleanup(cacheMocks.mockCache.StopRedisCallback)
	client, err := git.NewClient("file://"+dir, git.NopCreds{}, true, false, "", "", git.WithCache(cacheMocks.cache, false))
	require.NoError(t, err)
	refs, err := client.LsRefs()
	require.NoError(t, err)
	assert.NotNil(t, refs)
	assert.NotEmpty(t, refs.Branches, "Expected branches to be populated")
	assert.NotEmpty(t, refs.Branches[0])
	// Unlock should not have been called
	cacheMocks.mockCache.AssertNumberOfCalls(t, "UnlockGitReferences", 0)
	cacheMocks.mockCache.AssertNumberOfCalls(t, "GetOrLockGitReferences", 0)
}

func TestGetRefs_CacheWithLock(t *testing.T) {
	// Test that there is only one call to SetGitReferences for the same repo which is done after the ls-remote
	dir := t.TempDir()
	initGitRepo(t, newGitRepoOptions{
		path:           dir,
		createPath:     false,
		remote:         "",
		addEmptyCommit: true,
	})
	cacheMocks := newCacheMocks()
	t.Cleanup(cacheMocks.mockCache.StopRedisCallback)
	var wg sync.WaitGroup
	numberOfCallers := 10
	for range numberOfCallers {
		wg.Go(func() {
			client, err := git.NewClient("file://"+dir, git.NopCreds{}, true, false, "", "", git.WithCache(cacheMocks.cache, true))
			require.NoError(t, err)
			refs, err := client.LsRefs()
			require.NoError(t, err)
			assert.NotNil(t, refs)
			assert.NotEmpty(t, refs.Branches, "Expected branches to be populated")
			assert.NotEmpty(t, refs.Branches[0])
		})
	}
	wg.Wait()
	// Unlock should not have been called
	cacheMocks.mockCache.AssertNumberOfCalls(t, "UnlockGitReferences", 0)
	cacheMocks.mockCache.AssertNumberOfCalls(t, "GetOrLockGitReferences", 0)
}

func TestGetRefs_CacheUnlockedOnUpdateFailed(t *testing.T) {
	// Worst case the ttl on the lock expires and the lock is removed
	// however if the holder of the lock fails to update the cache the caller should remove the lock
	// to allow other callers to attempt to update the cache as quickly as possible
	dir := t.TempDir()
	initGitRepo(t, newGitRepoOptions{
		path:           dir,
		createPath:     false,
		remote:         "",
		addEmptyCommit: true,
	})
	cacheMocks := newCacheMocks()
	t.Cleanup(cacheMocks.mockCache.StopRedisCallback)
	repoURL := "file://" + dir
	client, err := git.NewClient(repoURL, git.NopCreds{}, true, false, "", "", git.WithCache(cacheMocks.cache, true))
	require.NoError(t, err)
	refs, err := client.LsRefs()
	require.NoError(t, err)
	assert.NotNil(t, refs)
	assert.NotEmpty(t, refs.Branches, "Expected branches to be populated")
	assert.NotEmpty(t, refs.Branches[0])
	var output [][2]string
	err = cacheMocks.cacheutilCache.GetItem(fmt.Sprintf("git-refs|%s|%s", repoURL, common.CacheVersion), &output)
	require.Error(t, err, "Should be a cache miss")
	assert.Empty(t, output, "Expected cache to be empty for key")
	cacheMocks.mockCache.AssertNumberOfCalls(t, "UnlockGitReferences", 0)
	cacheMocks.mockCache.AssertNumberOfCalls(t, "GetOrLockGitReferences", 0)
}

func TestGetRefs_CacheLockTryLockGitRefCacheError(t *testing.T) {
	// Worst case the ttl on the lock expires and the lock is removed
	// however if the holder of the lock fails to update the cache the caller should remove the lock
	// to allow other callers to attempt to update the cache as quickly as possible
	dir := t.TempDir()
	initGitRepo(t, newGitRepoOptions{
		path:           dir,
		createPath:     false,
		remote:         "",
		addEmptyCommit: true,
	})
	cacheMocks := newCacheMocks()
	t.Cleanup(cacheMocks.mockCache.StopRedisCallback)
	repoURL := "file://" + dir
	// buf := bytes.Buffer{}
	// log.SetOutput(&buf)
	client, err := git.NewClient(repoURL, git.NopCreds{}, true, false, "", "", git.WithCache(cacheMocks.cache, true))
	require.NoError(t, err)
	refs, err := client.LsRefs()
	require.NoError(t, err)
	assert.NotNil(t, refs)
}

func TestGetRevisionChartDetails(t *testing.T) {
	t.Run("Test revision semver", func(t *testing.T) {
		root := t.TempDir()
		service := newService(t, root)
		_, err := service.GetRevisionChartDetails(t.Context(), &apiclient.RepoServerRevisionChartDetailsRequest{
			Repo: &v1alpha1.Repository{
				Repo: "file://" + root,
				Name: "test-repo-name",
				Type: "helm",
			},
			Name:     "test-name",
			Revision: "test-revision",
		})
		assert.ErrorContains(t, err, "invalid revision")
	})

	t.Run("Test GetRevisionChartDetails", func(t *testing.T) {
		root := t.TempDir()
		service := newService(t, root)
		repoURL := "file://" + root
		err := service.cache.SetRevisionChartDetails(repoURL, "my-chart", "1.1.0", &v1alpha1.ChartDetails{
			Description: "test-description",
			Home:        "test-home",
			Maintainers: []string{"test-maintainer"},
		})
		require.NoError(t, err)
		chartDetails, err := service.GetRevisionChartDetails(t.Context(), &apiclient.RepoServerRevisionChartDetailsRequest{
			Repo: &v1alpha1.Repository{
				Repo: "file://" + root,
				Name: "test-repo-name",
				Type: "helm",
			},
			Name:     "my-chart",
			Revision: "1.1.0",
		})
		require.NoError(t, err)
		assert.Equal(t, "test-description", chartDetails.Description)
		assert.Equal(t, "test-home", chartDetails.Home)
		assert.Equal(t, []string{"test-maintainer"}, chartDetails.Maintainers)
	})
}

func TestVerifyCommitSignature(t *testing.T) {
	repo := &v1alpha1.Repository{
		Repo: "https://github.com/example/repo.git",
	}

	t.Run("VerifyCommitSignature with valid signature", func(t *testing.T) {
		t.Setenv("ARGOCD_GPG_ENABLED", "true")
		mockGitClient := &gitmocks.Client{}
		mockGitClient.EXPECT().VerifyCommitSignature(mock.Anything).
			Return(testSignature, nil)
		err := verifyCommitSignature(true, mockGitClient, "abcd1234", repo)
		require.NoError(t, err)
	})

	t.Run("VerifyCommitSignature with invalid signature", func(t *testing.T) {
		t.Setenv("ARGOCD_GPG_ENABLED", "true")
		mockGitClient := &gitmocks.Client{}
		mockGitClient.EXPECT().VerifyCommitSignature(mock.Anything).
			Return("", nil)
		err := verifyCommitSignature(true, mockGitClient, "abcd1234", repo)
		assert.EqualError(t, err, "revision abcd1234 is not signed")
	})

	t.Run("VerifyCommitSignature with unknown signature", func(t *testing.T) {
		t.Setenv("ARGOCD_GPG_ENABLED", "true")
		mockGitClient := &gitmocks.Client{}
		mockGitClient.EXPECT().VerifyCommitSignature(mock.Anything).
			Return("", errors.New("UNKNOWN signature: gpg: Unknown signature from ABCDEFGH"))
		err := verifyCommitSignature(true, mockGitClient, "abcd1234", repo)
		assert.EqualError(t, err, "UNKNOWN signature: gpg: Unknown signature from ABCDEFGH")
	})

	t.Run("VerifyCommitSignature with error verifying signature", func(t *testing.T) {
		t.Setenv("ARGOCD_GPG_ENABLED", "true")
		mockGitClient := &gitmocks.Client{}
		mockGitClient.EXPECT().VerifyCommitSignature(mock.Anything).
			Return("", errors.New("error verifying signature of commit 'abcd1234' in repo 'https://github.com/example/repo.git': failed to verify signature"))
		err := verifyCommitSignature(true, mockGitClient, "abcd1234", repo)
		assert.EqualError(t, err, "error verifying signature of commit 'abcd1234' in repo 'https://github.com/example/repo.git': failed to verify signature")
	})

	t.Run("VerifyCommitSignature with signature verification disabled", func(t *testing.T) {
		t.Setenv("ARGOCD_GPG_ENABLED", "false")
		mockGitClient := &gitmocks.Client{}
		err := verifyCommitSignature(false, mockGitClient, "abcd1234", repo)
		require.NoError(t, err)
	})
}

func Test_GenerateManifests_Commands(t *testing.T) {
	t.Run("helm", func(t *testing.T) {
		service := newService(t, "testdata/my-chart")

		// Fill the manifest request with as many parameters affecting Helm commands as possible.
		q := apiclient.ManifestRequest{
			AppName:     "test-app",
			Namespace:   "test-namespace",
			KubeVersion: "1.2.3+something",
			ApiVersions: []string{"v1/Test", "v2/Test"},
			Repo:        &v1alpha1.Repository{},
			ApplicationSource: &v1alpha1.ApplicationSource{
				Path: ".",
				Helm: &v1alpha1.ApplicationSourceHelm{
					FileParameters: []v1alpha1.HelmFileParameter{
						{
							Name: "test-file-param-name",
							Path: "test-file-param.yaml",
						},
					},
					Parameters: []v1alpha1.HelmParameter{
						{
							Name: "test-param-name",
							// Use build env var to test substitution.
							Value:       "test-value-$ARGOCD_APP_NAME",
							ForceString: true,
						},
						{
							Name: "test-param-bool-name",
							// Use build env var to test substitution.
							Value: "false",
						},
					},
					PassCredentials:      true,
					SkipCrds:             true,
					SkipSchemaValidation: false,
					ValueFiles: []string{
						"my-chart-values.yaml",
					},
					Values: "test: values",
				},
			},
			ProjectName:        "something",
			ProjectSourceRepos: []string{"*"},
		}

		res, err := service.GenerateManifest(t.Context(), &q)

		require.NoError(t, err)
		assert.Equal(t, []string{"helm template . --name-template test-app --namespace test-namespace --kube-version 1.2.3 --set test-param-bool-name=false --set-string test-param-name=test-value-test-app --set-file test-file-param-name=./test-file-param.yaml --values ./my-chart-values.yaml --values <temp file with values from source.helm.values/valuesObject> --api-versions v1/Test --api-versions v2/Test"}, res.Commands)

		t.Run("with overrides", func(t *testing.T) {
			// These can be set explicitly instead of using inferred values. Make sure the overrides apply.
			q.ApplicationSource.Helm.APIVersions = []string{"v3", "v4"}
			q.ApplicationSource.Helm.KubeVersion = "5.6.7+something"
			q.ApplicationSource.Helm.Namespace = "different-namespace"
			q.ApplicationSource.Helm.ReleaseName = "different-release-name"

			res, err = service.GenerateManifest(t.Context(), &q)

			require.NoError(t, err)
			assert.Equal(t, []string{"helm template . --name-template different-release-name --namespace different-namespace --kube-version 5.6.7 --set test-param-bool-name=false --set-string test-param-name=test-value-test-app --set-file test-file-param-name=./test-file-param.yaml --values ./my-chart-values.yaml --values <temp file with values from source.helm.values/valuesObject> --api-versions v3 --api-versions v4"}, res.Commands)
		})
	})

	t.Run("helm with dependencies", func(t *testing.T) {
		// This test makes sure we still get commands, even if we hit the code path that has to run "helm dependency build."
		// We don't actually return the "helm dependency build" command, because we expect that the user is able to read
		// the "helm template" and figure out how to fix it.
		t.Cleanup(func() {
			err := os.Remove("testdata/helm-with-local-dependency/Chart.lock")
			require.NoError(t, err)
			err = os.RemoveAll("testdata/helm-with-local-dependency/charts")
			require.NoError(t, err)
			err = os.Remove(path.Join("testdata/helm-with-local-dependency", helmDepUpMarkerFile))
			require.NoError(t, err)
		})

		service := newService(t, "testdata/helm-with-local-dependency")

		q := apiclient.ManifestRequest{
			AppName:   "test-app",
			Namespace: "test-namespace",
			Repo:      &v1alpha1.Repository{},
			ApplicationSource: &v1alpha1.ApplicationSource{
				Path: ".",
			},
			ProjectName:        "something",
			ProjectSourceRepos: []string{"*"},
		}

		res, err := service.GenerateManifest(t.Context(), &q)

		require.NoError(t, err)
		assert.Equal(t, []string{"helm template . --name-template test-app --namespace test-namespace --include-crds"}, res.Commands)
	})

	t.Run("kustomize", func(t *testing.T) {
		// Write test files to a temp dir, because the test mutates kustomization.yaml in place.
		tempDir := t.TempDir()
		err := os.WriteFile(path.Join(tempDir, "kustomization.yaml"), []byte(`
resources:
- guestbook.yaml
`), os.FileMode(0o600))
		require.NoError(t, err)
		err = os.WriteFile(path.Join(tempDir, "guestbook.yaml"), []byte(`
apiVersion: apps/v1
kind: Deployment
metadata:
  name: guestbook-ui
`), os.FileMode(0o400))
		require.NoError(t, err)
		err = os.Mkdir(path.Join(tempDir, "component"), os.FileMode(0o700))
		require.NoError(t, err)
		err = os.WriteFile(path.Join(tempDir, "component", "kustomization.yaml"), []byte(`
apiVersion: kustomize.config.k8s.io/v1alpha1
kind: Component
images:
- name: old
  newName: new
`), os.FileMode(0o400))
		require.NoError(t, err)

		service := newService(t, tempDir)

		// Fill the manifest request with as many parameters affecting Kustomize commands as possible.
		q := apiclient.ManifestRequest{
			AppName:     "test-app",
			Namespace:   "test-namespace",
			KubeVersion: "1.2.3+something",
			ApiVersions: []string{"v1/Test", "v2/Test"},
			Repo:        &v1alpha1.Repository{},
			KustomizeOptions: &v1alpha1.KustomizeOptions{
				BuildOptions: "--enable-helm",
			},
			ApplicationSource: &v1alpha1.ApplicationSource{
				Path: ".",
				Kustomize: &v1alpha1.ApplicationSourceKustomize{
					APIVersions: []string{"v1", "v2"},
					CommonAnnotations: map[string]string{
						// Use build env var to test substitution.
						"test": "annotation-$ARGOCD_APP_NAME",
					},
					CommonAnnotationsEnvsubst: true,
					CommonLabels: map[string]string{
						"test": "label",
					},
					Components:             []string{"component"},
					ForceCommonAnnotations: true,
					ForceCommonLabels:      true,
					Images: v1alpha1.KustomizeImages{
						"image=override",
					},
					KubeVersion:           "5.6.7+something",
					LabelWithoutSelector:  true,
					LabelIncludeTemplates: true,
					NamePrefix:            "test-prefix",
					NameSuffix:            "test-suffix",
					Namespace:             "override-namespace",
					Replicas: v1alpha1.KustomizeReplicas{
						{
							Name:  "guestbook-ui",
							Count: intstr.Parse("1337"),
						},
					},
				},
			},
			ProjectName:        "something",
			ProjectSourceRepos: []string{"*"},
		}

		res, err := service.GenerateManifest(t.Context(), &q)
		require.NoError(t, err)
		assert.Equal(t, []string{
			"kustomize edit set nameprefix -- test-prefix",
			"kustomize edit set namesuffix -- test-suffix",
			"kustomize edit set image image=override",
			"kustomize edit set replicas guestbook-ui=1337",
			"kustomize edit add label --force --without-selector --include-templates test:label",
			"kustomize edit add annotation --force test:annotation-test-app",
			"kustomize edit set namespace -- override-namespace",
			"kustomize edit add component component",
			"kustomize build . --enable-helm --helm-kube-version 5.6.7 --helm-api-versions v1 --helm-api-versions v2",
		}, res.Commands)
	})
}

func Test_SkipSchemaValidation(t *testing.T) {
	t.Run("helm", func(t *testing.T) {
		service := newService(t, "testdata/broken-schema-verification")

		q := apiclient.ManifestRequest{
			AppName: "test-app",
			Repo:    &v1alpha1.Repository{},
			ApplicationSource: &v1alpha1.ApplicationSource{
				Path: ".",
				Helm: &v1alpha1.ApplicationSourceHelm{
					SkipSchemaValidation: true,
				},
			},
		}

		res, err := service.GenerateManifest(t.Context(), &q)

		require.NoError(t, err)
		assert.Equal(t, []string{"helm template . --name-template test-app --include-crds --skip-schema-validation"}, res.Commands)
	})
	t.Run("helm", func(t *testing.T) {
		service := newService(t, "testdata/broken-schema-verification")

		q := apiclient.ManifestRequest{
			AppName: "test-app",
			Repo:    &v1alpha1.Repository{},
			ApplicationSource: &v1alpha1.ApplicationSource{
				Path: ".",
				Helm: &v1alpha1.ApplicationSourceHelm{
					SkipSchemaValidation: false,
				},
			},
		}

		_, err := service.GenerateManifest(t.Context(), &q)

		require.ErrorContains(t, err, "values don't meet the specifications of the schema(s)")
	})
}

func TestGenerateManifest_OCISourceSkipsGitClient(t *testing.T) {
	svc := newService(t, t.TempDir())

	gitCalled := false
	svc.newGitClient = func(_, _ string, _ git.Creds, _, _ bool, _, _ string, _ ...git.ClientOpts) (git.Client, error) {
		gitCalled = true
		return nil, errors.New("git should not be called for OCI")
	}

	req := &apiclient.ManifestRequest{
		HasMultipleSources: true,
		Repo: &v1alpha1.Repository{
			Repo: "oci://example.com/foo",
		},
		ApplicationSource: &v1alpha1.ApplicationSource{
			Path:           "",
			TargetRevision: "v1",
			Ref:            "foo",
			RepoURL:        "oci://example.com/foo",
		},
		ProjectName:        "foo-project",
		ProjectSourceRepos: []string{"*"},
	}

	_, err := svc.GenerateManifest(t.Context(), req)
	require.NoError(t, err)

	// verify that newGitClient was never invoked
	assert.False(t, gitCalled, "GenerateManifest should not invoke Git for OCI sources")
}
