Repository: argoproj/argo-cd
Pull request #8362 (2022-02-03 17:50:01)

docs: Remove the mention of CLA in toolchain guide
Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>

Note on DCO:

If the DCO action in the integration test fails, one or more of your commits are not signed off. Please click on the *Details* link next to the DCO action for instructions on how to resolve this.

Checklist:

* [x] Either (a) I've created an [enhancement proposal](https://github.com/argoproj/argo-cd/issues/new/choose) and discussed it with the community, (b) this is a bug fix, or (c) this does not need to be in the release notes.
* [x] The title of the PR states what changed and the related issues number (used for the release note).
* [x] I've included "Closes [ISSUE #]" or "Fixes [ISSUE #]" in the description to automatically close the associated issue.
* [x] I've updated both the CLI and UI to expose my feature, or I plan to submit a second PR with them.
* [x] Does this PR require documentation updates?
* [x] I've updated documentation as required by this PR.
* [x] Optional. My organization is added to USERS.md.
* [x] I have signed off all my commits as required by [DCO](https://github.com/argoproj/argoproj/tree/master/community#contributing-to-argo)
* [x] I have written unit and/or e2e tests for my change. PRs without these are unlikely to be merged.
* [x] My build is green ([troubleshooting builds](https://argo-cd.readthedocs.io/en/latest/developer-guide/ci/)). 




Patch:
diff --git a/docs/developer-guide/toolchain-guide.md b/docs/developer-guide/toolchain-guide.md
index 733d02aa6..d30183445 100644
--- a/docs/developer-guide/toolchain-guide.md
+++ b/docs/developer-guide/toolchain-guide.md
@@ -63,12 +63,6 @@ We use the [Semantic PR title checker](https://github.com/zeke/semantic-pull-req
 
 Please prefix the title of your PR with one of the valid categories. For example, if you chose the title your PR `Add documentation for GitHub SSO integration`, please use `docs: Add documentation for GitHub SSO integration` instead.
 
-### Contributor License Agreement
-
-Every contributor to Argo CD must have signed the current Contributor License Agreement (CLA). You only have to sign the CLA when you are a first time contributor, or when the agreement has changed since your last time signing it. The main purpose of the CLA is to ensure that you hold the required rights for your contribution. The CLA signing is an automated process.
-
-You can read the current version of the CLA [here](https://cla-assistant.io/argoproj/argo-cd).
-
 ### PR template checklist
 
 Upon opening a PR, the details will contain a checklist from a template. Please read the checklist, and tick those marks that apply to you.
diff --git a/reposerver/repository/repository.go b/reposerver/repository/repository.go
index 212ef5424..fd4871524 100644
--- a/reposerver/repository/repository.go
+++ b/reposerver/repository/repository.go
@@ -52,7 +52,6 @@ import (
 	"github.com/argoproj/argo-cd/v2/util/io"
 	"github.com/argoproj/argo-cd/v2/util/ksonnet"
 	"github.com/argoproj/argo-cd/v2/util/kustomize"
-	"github.com/argoproj/argo-cd/v2/util/security"
 	"github.com/argoproj/argo-cd/v2/util/text"
 )
 
@@ -66,6 +65,9 @@ const (
 	ociPrefix                      = "oci://"
 )
 
+// List of protocol schemes allowed for fetching remote value files
+var allowedHelmRemoteProtocols = []string{"http", "https"}
+
 // Service implements ManifestService interface
 type Service struct {
 	repoLock                  *repositoryLock
@@ -554,6 +556,146 @@ func runHelmBuild(appPath string, h helm.Helm) error {
 	return ioutil.WriteFile(markerFile, []byte("marker"), 0644)
 }
 
+// resolveSymbolicLinkRecursive resolves the symlink path recursively to its
+// canonical path on the file system, with a maximum nesting level of maxDepth.
+// If path is not a symlink, returns the verbatim copy of path and err of nil.
+func resolveSymbolicLinkRecursive(path string, maxDepth int) (string, error) {
+	resolved, err := os.Readlink(path)
+	if err != nil {
+		// path is not a symbolic link
+		_, ok := err.(*os.PathError)
+		if ok {
+			return path, nil
+		}
+		// Other error has occured
+		return "", err
+	}
+
+	if maxDepth == 0 {
+		return "", fmt.Errorf("maximum nesting level reached")
+	}
+
+	return resolveSymbolicLinkRecursive(resolved, maxDepth-1)
+}
+
+// isURLSchemeAllowed returns true if the protocol scheme is in the list of
+// allowed URL schemes.
+func isURLSchemeAllowed(scheme string, allowed []string) bool {
+	isAllowed := false
+	if len(allowed) > 0 {
+		for _, s := range allowed {
+			if strings.EqualFold(scheme, s) {
+				isAllowed = true
+				break
+			}
+		}
+	}
+
+	// Empty scheme means local file
+	return isAllowed && scheme != ""
+}
+
+// resolveHelmValueFilePath will inspect and resolve a path to a Helm value
+// file, and make sure that its final path is within the boundaries of the
+// path specified in repoRoot.
+//
+// appPath is the path we're operating in, e.g. where a Helm chart was unpacked
+// to. repoRoot is the path to the root of the repository.
+//
+// If either appPath or repoRoot is relative, it will be treated as relative
+// to the current working directory.
+//
+// valueFile is the path to a value file, relative to appPath. If valueFile is
+// specified as an absolute path (i.e. leading slash), it will be treated as
+// relative to the repoRoot. In case valueFile is a symlink in the extracted
+// chart, it will be resolved recursively and the decision of whether it is in
+// the boundary of repoRoot will be made using the final resolved path.
+// valueFile can also be a remote URL with a protocol scheme as prefix,
+// in which case the scheme must be included in the list of allowed schemes
+// specified by allowedURLSchemes.
+//
+// Will return an error if either valueFile is outside the boundaries of the
+// repoRoot, valueFile is an URL with a forbidden protocol scheme or if
+// valueFile is a recursive symlink nested too deep. May return errors for
+// other reasons as well.
+//
+// resolvedPath will hold the absolute, resolved path for valueFile on success
+// or set to the empty string on failure.
+//
+// isRemote will be set to true if valueFile is an URL using an allowed
+// protocol scheme, or to false if it resolved to a local file.
+func resolveHelmValueFilePath(appPath, repoRoot, valueFile string, allowedURLSchemes []string) (resolvedPath string, isRemote bool, err error) {
+
+	// We do not provide the path in the error message, because it will be
+	// returned to the user and could be used for information gathering.
+	// Instead, we log the concrete error details.
+	resolveFailure := func(path string, err error) error {
+		log.Errorf("failed to resolve path '%s': %v", path, err)
+		return fmt.Errorf("internal error: failed to resolve path. Check logs for more details")
+	}
+
+	// A value file can be specified as an URL to a remote resource.
+	// We only allow certain URL schemes for remote value files.
+	url, err := url.Parse(valueFile)
+	if err == nil {
+		// If scheme is empty, it means we parsed a path only
+		if url.Scheme != "" {
+			if isURLSchemeAllowed(url.Scheme, allowedURLSchemes) {
+				return valueFile, true, nil
+			} else {
+				return "", false, fmt.Errorf("the URL scheme '%s' is not allowed", url.Scheme)
+			}
+		}
+	}
+
+	// Ensure that our repository root is absolute
+	absRepoPath, err := filepath.Abs(repoRoot)
+	if err != nil {
+		return "", false, resolveFailure(repoRoot, err)
+	}
+
+	// If the path to the file is relative, join it with the current working directory (appPath)
+	// Otherwise, join it with the repository's root
+	path := valueFile
+	if !filepath.IsAbs(path) {
+		absWorkDir, err := filepath.Abs(appPath)
+		if err != nil {
+			return "", false, resolveFailure(repoRoot, err)
+		}
+		path = filepath.Join(absWorkDir, path)
+	} else {
+		path = filepath.Join(absRepoPath, path)
+	}
+
+	// Ensure any symbolic link is resolved before we
+	delinkedPath, err := resolveSymbolicLinkRecursive(path, 10)
+	if err != nil {
+		return "", false, resolveFailure(path, err)
+	}
+	path = delinkedPath
+
+	// Resolve the joined path to an absolute path
+	path, err = filepath.Abs(path)
+	if err != nil {
+		return "", false, resolveFailure(path, err)
+	}
+
+	// Ensure our root path has a trailing slash, otherwise the following check
+	// would return true if root is /foo and path would be /foo2
+	requiredRootPath := absRepoPath
+	if !strings.HasSuffix(requiredRootPath, "/") {
+		requiredRootPath += "/"
+	}
+
+	// Make sure that the resolved path to values file is within the repository's root path
+	if !strings.HasPrefix(path, requiredRootPath) {
+		return "", false, fmt.Errorf("value file '%s' resolved to outside repository root", valueFile)
+	}
+
+	return path, false, nil
+
+}
+
 func helmTemplate(appPath string, repoRoot string, env *v1alpha1.Env, q *apiclient.ManifestRequest, isLocal bool) ([]*unstructured.Unstructured, error) {
 	concurrencyAllowed := isConcurrencyAllowed(appPath)
 	if !concurrencyAllowed {
@@ -583,31 +725,14 @@ func helmTemplate(appPath string, repoRoot string, env *v1alpha1.Env, q *apiclie
 		}
 
 		for _, val := range appHelm.ValueFiles {
-			// If val is not a URL, run it against the directory enforcer. If it is a URL, use it without checking
-			// If val does not exist, warn. If IgnoreMissingValueFiles, do not append, else let Helm handle it.
-			if _, err := url.ParseRequestURI(val); err != nil {
-
-				// Ensure that the repo root provided is absolute
-				absRepoPath, err := filepath.Abs(repoRoot)
-				if err != nil {
-					return nil, err
-				}
-
-				// If the path to the file is relative, join it with the current working directory (appPath)
-				path := val
-				if !filepath.IsAbs(path) {
-					absWorkDir, err := filepath.Abs(appPath)
-					if err != nil {
-						return nil, err
-					}
-					path = filepath.Join(absWorkDir, path)
-				}
 
-				_, err = security.EnforceToCurrentRoot(absRepoPath, path)
-				if err != nil {
-					return nil, err
-				}
+			// This will resolve val to an absolute path (or an URL)
+			path, isRemote, err := resolveHelmValueFilePath(appPath, repoRoot, val, allowedHelmRemoteProtocols)
+			if err != nil {
+				return nil, err
+			}
 
+			if !isRemote {
 				_, err = os.Stat(path)
 				if os.IsNotExist(err) {
 					if appHelm.IgnoreMissingValueFiles {
@@ -616,7 +741,8 @@ func helmTemplate(appPath string, repoRoot string, env *v1alpha1.Env, q *apiclie
 					}
 				}
 			}
-			templateOpts.Values = append(templateOpts.Values, val)
+
+			templateOpts.Values = append(templateOpts.Values, path)
 		}
 
 		if appHelm.Values != "" {
diff --git a/resource_customizations/cert-manager.io/Certificate/health.lua b/resource_customizations/cert-manager.io/Certificate/health.lua
index 61b28c84b..209ebb596 100644
--- a/resource_customizations/cert-manager.io/Certificate/health.lua
+++ b/resource_customizations/cert-manager.io/Certificate/health.lua
@@ -2,6 +2,11 @@ hs = {}
 if obj.status ~= nil then
   if obj.status.conditions ~= nil then
     for i, condition in ipairs(obj.status.conditions) do
+      if condition.type == "Issuing" and condition.status == "True" then
+        hs.status = "Progressing"
+        hs.message = condition.message
+        return hs
+      end
       if condition.type == "Ready" and condition.status == "False" then
         hs.status = "Degraded"
         hs.message = condition.message


Test patch:
diff --git a/reposerver/repository/repository_test.go b/reposerver/repository/repository_test.go
index 0584c807b..f6f4c8687 100644
--- a/reposerver/repository/repository_test.go
+++ b/reposerver/repository/repository_test.go
@@ -754,7 +754,7 @@ func TestHelmManifestFromChartRepoWithValueFileOutsideRepo(t *testing.T) {
 	}
 	request := &apiclient.ManifestRequest{Repo: &argoappv1.Repository{}, ApplicationSource: source, NoCache: true}
 	_, err := service.GenerateManifest(context.Background(), request)
-	assert.Error(t, err, "should be on or under current directory")
+	assert.Error(t, err)
 }
 
 func TestGenerateHelmWithURL(t *testing.T) {
@@ -777,33 +777,88 @@ func TestGenerateHelmWithURL(t *testing.T) {
 // The requested value file (`../../../../../minio/values.yaml`) is outside the repo directory
 // (`~/go/src/github.com/argoproj/argo-cd`), so it is blocked
 func TestGenerateHelmWithValuesDirectoryTraversalOutsideRepo(t *testing.T) {
-	service := newService("../..")
-	_, err := service.GenerateManifest(context.Background(), &apiclient.ManifestRequest{
-		Repo:    &argoappv1.Repository{},
-		AppName: "test",
-		ApplicationSource: &argoappv1.ApplicationSource{
-			Path: "./util/helm/testdata/redis",
-			Helm: &argoappv1.ApplicationSourceHelm{
-				ValueFiles: []string{"../../../../../minio/values.yaml"},
-				Values:     `cluster: {slaveCount: 2}`,
+	t.Run("Values file with relative path pointing outside repo root", func(t *testing.T) {
+		service := newService("../..")
+		_, err := service.GenerateManifest(context.Background(), &apiclient.ManifestRequest{
+			Repo:    &argoappv1.Repository{},
+			AppName: "test",
+			ApplicationSource: &argoappv1.ApplicationSource{
+				Path: "./util/helm/testdata/redis",
+				Helm: &argoappv1.ApplicationSourceHelm{
+					ValueFiles: []string{"../../../../../minio/values.yaml"},
+					Values:     `cluster: {slaveCount: 2}`,
+				},
 			},
-		},
+		})
+		assert.Error(t, err)
+		assert.Contains(t, err.Error(), "outside repository root")
 	})
-	assert.Error(t, err, "should be on or under current directory")
 
-	service = newService("./testdata/my-chart")
-	_, err = service.GenerateManifest(context.Background(), &apiclient.ManifestRequest{
-		Repo:    &argoappv1.Repository{},
-		AppName: "test",
-		ApplicationSource: &argoappv1.ApplicationSource{
-			Path: ".",
-			Helm: &argoappv1.ApplicationSourceHelm{
-				ValueFiles: []string{"../my-chart-2/values.yaml"},
-				Values:     `cluster: {slaveCount: 2}`,
+	t.Run("Values file with relative path pointing inside repo root", func(t *testing.T) {
+		service := newService("./testdata/my-chart")
+		_, err := service.GenerateManifest(context.Background(), &apiclient.ManifestRequest{
+			Repo:    &argoappv1.Repository{},
+			AppName: "test",
+			ApplicationSource: &argoappv1.ApplicationSource{
+				Path: ".",
+				Helm: &argoappv1.ApplicationSourceHelm{
+					ValueFiles: []string{"../my-chart/my-chart-values.yaml"},
+					Values:     `cluster: {slaveCount: 2}`,
+				},
 			},
-		},
+		})
+		assert.NoError(t, err)
+	})
+
+	t.Run("Values file with absolute path stays within repo root", func(t *testing.T) {
+		service := newService("./testdata/my-chart")
+		_, err := service.GenerateManifest(context.Background(), &apiclient.ManifestRequest{
+			Repo:    &argoappv1.Repository{},
+			AppName: "test",
+			ApplicationSource: &argoappv1.ApplicationSource{
+				Path: ".",
+				Helm: &argoappv1.ApplicationSourceHelm{
+					ValueFiles: []string{"/my-chart-values.yaml"},
+					Values:     `cluster: {slaveCount: 2}`,
+				},
+			},
+		})
+		assert.NoError(t, err)
+	})
+
+	t.Run("Values file with absolute path using back-references outside repo root", func(t *testing.T) {
+		service := newService("./testdata/my-chart")
+		_, err := service.GenerateManifest(context.Background(), &apiclient.ManifestRequest{
+			Repo:    &argoappv1.Repository{},
+			AppName: "test",
+			ApplicationSource: &argoappv1.ApplicationSource{
+				Path: ".",
+				Helm: &argoappv1.ApplicationSourceHelm{
+					ValueFiles: []string{"/../../../my-chart-values.yaml"},
+					Values:     `cluster: {slaveCount: 2}`,
+				},
+			},
+		})
+		assert.Error(t, err)
+		assert.Contains(t, err.Error(), "outside repository root")
+	})
+
+	t.Run("Remote values file from forbidden protocol", func(t *testing.T) {
+		service := newService("./testdata/my-chart")
+		_, err := service.GenerateManifest(context.Background(), &apiclient.ManifestRequest{
+			Repo:    &argoappv1.Repository{},
+			AppName: "test",
+			ApplicationSource: &argoappv1.ApplicationSource{
+				Path: ".",
+				Helm: &argoappv1.ApplicationSourceHelm{
+					ValueFiles: []string{"file://../../../../my-chart-values.yaml"},
+					Values:     `cluster: {slaveCount: 2}`,
+				},
+			},
+		})
+		assert.Error(t, err)
+		assert.Contains(t, err.Error(), "is not allowed")
 	})
-	assert.Error(t, err, "should be on or under current directory")
 }
 
 // The requested file parameter (`/tmp/external-secret.txt`) is outside the app path
@@ -1631,3 +1686,178 @@ func TestResolveRevisionNegativeScenarios(t *testing.T) {
 	assert.Equal(t, expectedResolveRevisionResponse, resolveRevisionResponse)
 
 }
+
+func Test_resolveSymlinkRecursive(t *testing.T) {
+	cwd, err := os.Getwd()
+	require.NoError(t, err)
+	err = os.Chdir("testdata/symlinks")
+	require.NoError(t, err)
+	defer func() {
+		err := os.Chdir(cwd)
+		if err != nil {
+			panic(err)
+		}
+	}()
+	t.Run("Resolve non-symlink", func(t *testing.T) {
+		r, err := resolveSymbolicLinkRecursive("foo", 2)
+		assert.NoError(t, err)
+		assert.Equal(t, "foo", r)
+	})
+	t.Run("Successfully resolve symlink", func(t *testing.T) {
+		r, err := resolveSymbolicLinkRecursive("bar", 2)
+		assert.NoError(t, err)
+		assert.Equal(t, "foo", r)
+	})
+	t.Run("Do not allow symlink at all", func(t *testing.T) {
+		r, err := resolveSymbolicLinkRecursive("bar", 0)
+		assert.Error(t, err)
+		assert.Equal(t, "", r)
+	})
+	t.Run("Error because too nested symlink", func(t *testing.T) {
+		r, err := resolveSymbolicLinkRecursive("bam", 2)
+		assert.Error(t, err)
+		assert.Equal(t, "", r)
+	})
+	t.Run("No such file or directory", func(t *testing.T) {
+		r, err := resolveSymbolicLinkRecursive("foobar", 2)
+		assert.NoError(t, err)
+		assert.Equal(t, "foobar", r)
+	})
+}
+
+func Test_isURLSchemeAllowed(t *testing.T) {
+	type testdata struct {
+		name     string
+		scheme   string
+		allowed  []string
+		expected bool
+	}
+	var tts []testdata = []testdata{
+		{
+			name:     "Allowed scheme matches",
+			scheme:   "http",
+			allowed:  []string{"http", "https"},
+			expected: true,
+		},
+		{
+			name:     "Allowed scheme matches only partially",
+			scheme:   "http",
+			allowed:  []string{"https"},
+			expected: false,
+		},
+		{
+			name:     "Scheme is not allowed",
+			scheme:   "file",
+			allowed:  []string{"http", "https"},
+			expected: false,
+		},
+		{
+			name:     "Empty scheme with valid allowances is forbidden",
+			scheme:   "",
+			allowed:  []string{"http", "https"},
+			expected: false,
+		},
+		{
+			name:     "Empty scheme with empty allowances is forbidden",
+			scheme:   "",
+			allowed:  []string{},
+			expected: false,
+		},
+		{
+			name:     "Some scheme with empty allowances is forbidden",
+			scheme:   "file",
+			allowed:  []string{},
+			expected: false,
+		},
+	}
+	for _, tt := range tts {
+		t.Run(tt.name, func(t *testing.T) {
+			r := isURLSchemeAllowed(tt.scheme, tt.allowed)
+			assert.Equal(t, tt.expected, r)
+		})
+	}
+}
+
+func Test_resolveHelmValueFilePath(t *testing.T) {
+	t.Run("Resolve normal relative path into absolute path", func(t *testing.T) {
+		p, remote, err := resolveHelmValueFilePath("/foo/bar", "/foo", "baz/bim.yaml", allowedHelmRemoteProtocols)
+		assert.NoError(t, err)
+		assert.False(t, remote)
+		assert.Equal(t, "/foo/bar/baz/bim.yaml", p)
+	})
+	t.Run("Resolve normal relative path into absolute path", func(t *testing.T) {
+		p, remote, err := resolveHelmValueFilePath("/foo/bar", "/foo", "baz/../../bim.yaml", allowedHelmRemoteProtocols)
+		assert.NoError(t, err)
+		assert.False(t, remote)
+		assert.Equal(t, "/foo/bim.yaml", p)
+	})
+	t.Run("Error on path resolving outside repository root", func(t *testing.T) {
+		p, remote, err := resolveHelmValueFilePath("/foo/bar", "/foo", "baz/../../../bim.yaml", allowedHelmRemoteProtocols)
+		assert.Error(t, err)
+		assert.Contains(t, err.Error(), "outside repository root")
+		assert.False(t, remote)
+		assert.Equal(t, "", p)
+	})
+	t.Run("Return verbatim URL", func(t *testing.T) {
+		url := "https://some.where/foo,yaml"
+		p, remote, err := resolveHelmValueFilePath("/foo/bar", "/foo", url, allowedHelmRemoteProtocols)
+		assert.NoError(t, err)
+		assert.True(t, remote)
+		assert.Equal(t, url, p)
+	})
+	t.Run("URL scheme not allowed", func(t *testing.T) {
+		url := "file:///some.where/foo,yaml"
+		p, remote, err := resolveHelmValueFilePath("/foo/bar", "/foo", url, allowedHelmRemoteProtocols)
+		assert.Error(t, err)
+		assert.False(t, remote)
+		assert.Equal(t, "", p)
+	})
+	t.Run("Implicit URL by absolute path", func(t *testing.T) {
+		p, remote, err := resolveHelmValueFilePath("/foo/bar", "/foo", "/baz.yaml", allowedHelmRemoteProtocols)
+		assert.NoError(t, err)
+		assert.False(t, remote)
+		assert.Equal(t, "/foo/baz.yaml", p)
+	})
+	t.Run("Relative app path", func(t *testing.T) {
+		p, remote, err := resolveHelmValueFilePath(".", "/foo", "/baz.yaml", allowedHelmRemoteProtocols)
+		assert.NoError(t, err)
+		assert.False(t, remote)
+		assert.Equal(t, "/foo/baz.yaml", p)
+	})
+	t.Run("Relative repo path", func(t *testing.T) {
+		c, err := os.Getwd()
+		require.NoError(t, err)
+		p, remote, err := resolveHelmValueFilePath(".", ".", "baz.yaml", allowedHelmRemoteProtocols)
+		assert.NoError(t, err)
+		assert.False(t, remote)
+		assert.Equal(t, c+"/baz.yaml", p)
+	})
+	t.Run("Overlapping root prefix without trailing slash", func(t *testing.T) {
+		p, remote, err := resolveHelmValueFilePath(".", "/foo", "../foo2/baz.yaml", allowedHelmRemoteProtocols)
+		assert.Error(t, err)
+		assert.Contains(t, err.Error(), "outside repository root")
+		assert.False(t, remote)
+		assert.Equal(t, "", p)
+	})
+	t.Run("Overlapping root prefix with trailing slash", func(t *testing.T) {
+		p, remote, err := resolveHelmValueFilePath(".", "/foo/", "../foo2/baz.yaml", allowedHelmRemoteProtocols)
+		assert.Error(t, err)
+		assert.Contains(t, err.Error(), "outside repository root")
+		assert.False(t, remote)
+		assert.Equal(t, "", p)
+	})
+	t.Run("Garbage input as values file", func(t *testing.T) {
+		p, remote, err := resolveHelmValueFilePath(".", "/foo/", "kfdj\\ks&&&321209.,---e32908923%$§!\"", allowedHelmRemoteProtocols)
+		assert.Error(t, err)
+		assert.Contains(t, err.Error(), "outside repository root")
+		assert.False(t, remote)
+		assert.Equal(t, "", p)
+	})
+	t.Run("NUL-byte path input as values file", func(t *testing.T) {
+		p, remote, err := resolveHelmValueFilePath(".", "/foo/", "\000", allowedHelmRemoteProtocols)
+		assert.Error(t, err)
+		assert.Contains(t, err.Error(), "outside repository root")
+		assert.False(t, remote)
+		assert.Equal(t, "", p)
+	})
+}
diff --git a/reposerver/repository/testdata/symlinks/bam b/reposerver/repository/testdata/symlinks/bam
new file mode 120000
index 000000000..3f9538666
--- /dev/null
+++ b/reposerver/repository/testdata/symlinks/bam
@@ -0,0 +1,1 @@
+baz
\ No newline at end of file
diff --git a/reposerver/repository/testdata/symlinks/bar b/reposerver/repository/testdata/symlinks/bar
new file mode 120000
index 000000000..191028156
--- /dev/null
+++ b/reposerver/repository/testdata/symlinks/bar
@@ -0,0 +1,1 @@
+foo
\ No newline at end of file
diff --git a/reposerver/repository/testdata/symlinks/baz b/reposerver/repository/testdata/symlinks/baz
new file mode 120000
index 000000000..ba0e162e1
--- /dev/null
+++ b/reposerver/repository/testdata/symlinks/baz
@@ -0,0 +1,1 @@
+bar
\ No newline at end of file
diff --git a/reposerver/repository/testdata/symlinks/foo b/reposerver/repository/testdata/symlinks/foo
new file mode 100644
index 000000000..ce0136250
--- /dev/null
+++ b/reposerver/repository/testdata/symlinks/foo
@@ -0,0 +1,1 @@
+hello
diff --git a/resource_customizations/cert-manager.io/Certificate/health_test.yaml b/resource_customizations/cert-manager.io/Certificate/health_test.yaml
index 9b0042953..ebf8e75e8 100644
--- a/resource_customizations/cert-manager.io/Certificate/health_test.yaml
+++ b/resource_customizations/cert-manager.io/Certificate/health_test.yaml
@@ -3,6 +3,10 @@ tests:
     status: Progressing
     message: Waiting for certificate
   inputPath: testdata/progressing_noStatus.yaml
+- healthStatus:
+    status: Progressing
+    message: Issuing certificate as Secret does not exist
+  inputPath: testdata/progressing_issuing.yaml
 - healthStatus:
     status: Degraded
     message: 'Resource validation failed: spec.acme.config: Required value: no ACME
diff --git a/resource_customizations/cert-manager.io/Certificate/testdata/progressing_issuing.yaml b/resource_customizations/cert-manager.io/Certificate/testdata/progressing_issuing.yaml
new file mode 100644
index 000000000..acde21e3d
--- /dev/null
+++ b/resource_customizations/cert-manager.io/Certificate/testdata/progressing_issuing.yaml
@@ -0,0 +1,37 @@
+apiVersion: cert-manager.io/v1alpha2
+kind: Certificate
+metadata:
+  creationTimestamp: '2018-11-07T00:06:12Z'
+  generation: 1
+  name: test-cert
+  namespace: argocd
+  resourceVersion: '64763033'
+  selfLink: /apis/cert-manager.io/v1alpha2/namespaces/argocd/certificates/test-cert
+  uid: e6cfba50-314d-11e9-be3f-42010a800011
+spec:
+  acme:
+    config:
+      - domains:
+          - cd.apps.argoproj.io
+        http01:
+          ingress: http01
+  commonName: cd.apps.argoproj.io
+  dnsNames:
+    - cd.apps.argoproj.io
+  issuerRef:
+    kind: Issuer
+    name: argo-cd-issuer
+  secretName: test-secret
+status:
+  conditions:
+    - lastTransitionTime: '2021-09-15T02:10:00Z'
+      message: Issuing certificate as Secret does not exist
+      reason: DoesNotExist
+      status: 'True'
+      type: Issuing
+    - lastTransitionTime: '2021-09-15T02:10:00Z'
+      message: Issuing certificate as Secret does not exist
+      reason: DoesNotExist
+      status: 'False'
+      type: Ready
+