github.com/argoproj/argo-cd/v3@v3.2.1/util/helm/client_test.go (about)

     1  package helm
     2  
     3  import (
     4  	"bytes"
     5  	"encoding/json"
     6  	"fmt"
     7  	"math"
     8  	"net/http"
     9  	"net/http/httptest"
    10  	"net/url"
    11  	"os"
    12  	"path/filepath"
    13  	"strings"
    14  	"testing"
    15  
    16  	"github.com/stretchr/testify/assert"
    17  	"github.com/stretchr/testify/require"
    18  	"gopkg.in/yaml.v2"
    19  
    20  	utilio "github.com/argoproj/argo-cd/v3/util/io"
    21  	"github.com/argoproj/argo-cd/v3/util/workloadidentity"
    22  	"github.com/argoproj/argo-cd/v3/util/workloadidentity/mocks"
    23  )
    24  
    25  type fakeIndexCache struct {
    26  	data []byte
    27  }
    28  
    29  type fakeTagsList struct {
    30  	Tags []string `json:"tags"`
    31  }
    32  
    33  func (f *fakeIndexCache) SetHelmIndex(_ string, indexData []byte) error {
    34  	f.data = indexData
    35  	return nil
    36  }
    37  
    38  func (f *fakeIndexCache) GetHelmIndex(_ string, indexData *[]byte) error {
    39  	*indexData = f.data
    40  	return nil
    41  }
    42  
    43  func TestIndex(t *testing.T) {
    44  	t.Run("Invalid", func(t *testing.T) {
    45  		client := NewClient("", HelmCreds{}, false, "", "")
    46  		_, err := client.GetIndex(false, 10000)
    47  		require.Error(t, err)
    48  	})
    49  	t.Run("Stable", func(t *testing.T) {
    50  		client := NewClient("https://argoproj.github.io/argo-helm", HelmCreds{}, false, "", "")
    51  		index, err := client.GetIndex(false, 10000)
    52  		require.NoError(t, err)
    53  		assert.NotNil(t, index)
    54  	})
    55  	t.Run("BasicAuth", func(t *testing.T) {
    56  		client := NewClient("https://argoproj.github.io/argo-helm", HelmCreds{
    57  			Username: "my-password",
    58  			Password: "my-username",
    59  		}, false, "", "")
    60  		index, err := client.GetIndex(false, 10000)
    61  		require.NoError(t, err)
    62  		assert.NotNil(t, index)
    63  	})
    64  
    65  	t.Run("Cached", func(t *testing.T) {
    66  		fakeIndex := Index{Entries: map[string]Entries{"fake": {}}}
    67  		data := bytes.Buffer{}
    68  		err := yaml.NewEncoder(&data).Encode(fakeIndex)
    69  		require.NoError(t, err)
    70  
    71  		client := NewClient("https://argoproj.github.io/argo-helm", HelmCreds{}, false, "", "", WithIndexCache(&fakeIndexCache{data: data.Bytes()}))
    72  		index, err := client.GetIndex(false, 10000)
    73  
    74  		require.NoError(t, err)
    75  		assert.Equal(t, fakeIndex, *index)
    76  	})
    77  
    78  	t.Run("Limited", func(t *testing.T) {
    79  		client := NewClient("https://argoproj.github.io/argo-helm", HelmCreds{}, false, "", "")
    80  		_, err := client.GetIndex(false, 100)
    81  
    82  		assert.ErrorContains(t, err, "unexpected end of stream")
    83  	})
    84  }
    85  
    86  func Test_nativeHelmChart_ExtractChart(t *testing.T) {
    87  	client := NewClient("https://argoproj.github.io/argo-helm", HelmCreds{}, false, "", "")
    88  	path, closer, err := client.ExtractChart("argo-cd", "0.7.1", false, math.MaxInt64, true)
    89  	require.NoError(t, err)
    90  	defer utilio.Close(closer)
    91  	info, err := os.Stat(path)
    92  	require.NoError(t, err)
    93  	assert.True(t, info.IsDir())
    94  }
    95  
    96  func Test_nativeHelmChart_ExtractChartWithLimiter(t *testing.T) {
    97  	client := NewClient("https://argoproj.github.io/argo-helm", HelmCreds{}, false, "", "")
    98  	_, _, err := client.ExtractChart("argo-cd", "0.7.1", false, 100, false)
    99  	require.Error(t, err, "error while iterating on tar reader: unexpected EOF")
   100  }
   101  
   102  func Test_nativeHelmChart_ExtractChart_insecure(t *testing.T) {
   103  	client := NewClient("https://argoproj.github.io/argo-helm", HelmCreds{InsecureSkipVerify: true}, false, "", "")
   104  	path, closer, err := client.ExtractChart("argo-cd", "0.7.1", false, math.MaxInt64, true)
   105  	require.NoError(t, err)
   106  	defer utilio.Close(closer)
   107  	info, err := os.Stat(path)
   108  	require.NoError(t, err)
   109  	assert.True(t, info.IsDir())
   110  }
   111  
   112  func Test_normalizeChartName(t *testing.T) {
   113  	t.Run("Test non-slashed name", func(t *testing.T) {
   114  		n := normalizeChartName("mychart")
   115  		assert.Equal(t, "mychart", n)
   116  	})
   117  	t.Run("Test single-slashed name", func(t *testing.T) {
   118  		n := normalizeChartName("myorg/mychart")
   119  		assert.Equal(t, "mychart", n)
   120  	})
   121  	t.Run("Test chart name with suborg", func(t *testing.T) {
   122  		n := normalizeChartName("myorg/mysuborg/mychart")
   123  		assert.Equal(t, "mychart", n)
   124  	})
   125  	t.Run("Test double-slashed name", func(t *testing.T) {
   126  		n := normalizeChartName("myorg//mychart")
   127  		assert.Equal(t, "mychart", n)
   128  	})
   129  	t.Run("Test invalid chart name - ends with slash", func(t *testing.T) {
   130  		n := normalizeChartName("myorg/")
   131  		assert.Equal(t, "myorg/", n)
   132  	})
   133  	t.Run("Test invalid chart name - is dot", func(t *testing.T) {
   134  		n := normalizeChartName("myorg/.")
   135  		assert.Equal(t, "myorg/.", n)
   136  	})
   137  	t.Run("Test invalid chart name - is two dots", func(t *testing.T) {
   138  		n := normalizeChartName("myorg/..")
   139  		assert.Equal(t, "myorg/..", n)
   140  	})
   141  }
   142  
   143  func TestIsHelmOciRepo(t *testing.T) {
   144  	assert.True(t, IsHelmOciRepo("demo.goharbor.io"))
   145  	assert.True(t, IsHelmOciRepo("demo.goharbor.io:8080"))
   146  	assert.False(t, IsHelmOciRepo("https://demo.goharbor.io"))
   147  	assert.False(t, IsHelmOciRepo("https://demo.goharbor.io:8080"))
   148  }
   149  
   150  func TestGetIndexURL(t *testing.T) {
   151  	urlTemplate := `https://gitlab.com/projects/%s/packages/helm/stable`
   152  	t.Run("URL without escaped characters", func(t *testing.T) {
   153  		rawURL := fmt.Sprintf(urlTemplate, "232323982")
   154  		want := rawURL + "/index.yaml"
   155  		got, err := getIndexURL(rawURL)
   156  		assert.Equal(t, want, got)
   157  		require.NoError(t, err)
   158  	})
   159  	t.Run("URL with escaped characters", func(t *testing.T) {
   160  		rawURL := fmt.Sprintf(urlTemplate, "mygroup%2Fmyproject")
   161  		want := rawURL + "/index.yaml"
   162  		got, err := getIndexURL(rawURL)
   163  		assert.Equal(t, want, got)
   164  		require.NoError(t, err)
   165  	})
   166  	t.Run("URL with invalid escaped characters", func(t *testing.T) {
   167  		rawURL := fmt.Sprintf(urlTemplate, "mygroup%**myproject")
   168  		got, err := getIndexURL(rawURL)
   169  		assert.Empty(t, got)
   170  		require.Error(t, err)
   171  	})
   172  }
   173  
   174  func TestGetTagsFromUrl(t *testing.T) {
   175  	t.Run("should return tags correctly while following the link header", func(t *testing.T) {
   176  		server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   177  			t.Logf("called %s", r.URL.Path)
   178  			var responseTags fakeTagsList
   179  			w.Header().Set("Content-Type", "application/json")
   180  			if !strings.Contains(r.URL.String(), "token") {
   181  				w.Header().Set("Link", fmt.Sprintf("<https://%s%s?token=next-token>; rel=next", r.Host, r.URL.Path))
   182  				responseTags = fakeTagsList{
   183  					Tags: []string{"first"},
   184  				}
   185  			} else {
   186  				responseTags = fakeTagsList{
   187  					Tags: []string{
   188  						"second",
   189  						"2.8.0",
   190  						"2.8.0-prerelease",
   191  						"2.8.0_build",
   192  						"2.8.0-prerelease_build",
   193  						"2.8.0-prerelease.1_build.1234",
   194  					},
   195  				}
   196  			}
   197  			w.WriteHeader(http.StatusOK)
   198  			require.NoError(t, json.NewEncoder(w).Encode(responseTags))
   199  		}))
   200  
   201  		client := NewClient(server.URL, HelmCreds{InsecureSkipVerify: true}, true, "", "")
   202  
   203  		tags, err := client.GetTags("mychart", true)
   204  		require.NoError(t, err)
   205  		assert.ElementsMatch(t, tags, []string{
   206  			"first",
   207  			"second",
   208  			"2.8.0",
   209  			"2.8.0-prerelease",
   210  			"2.8.0+build",
   211  			"2.8.0-prerelease+build",
   212  			"2.8.0-prerelease.1+build.1234",
   213  		})
   214  	})
   215  
   216  	t.Run("should return an error not when oci is not enabled", func(t *testing.T) {
   217  		client := NewClient("example.com", HelmCreds{}, false, "", "")
   218  
   219  		_, err := client.GetTags("my-chart", true)
   220  		assert.ErrorIs(t, ErrOCINotEnabled, err)
   221  	})
   222  }
   223  
   224  func TestGetTagsFromURLPrivateRepoAuthentication(t *testing.T) {
   225  	username := "my-username"
   226  	password := "my-password"
   227  	expectedAuthorization := "Basic bXktdXNlcm5hbWU6bXktcGFzc3dvcmQ=" // base64(user:password)
   228  	server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   229  		t.Logf("called %s", r.URL.Path)
   230  
   231  		authorization := r.Header.Get("Authorization")
   232  
   233  		if authorization == "" {
   234  			w.Header().Set("WWW-Authenticate", `Basic realm="helm repo to get tags"`)
   235  			w.WriteHeader(http.StatusUnauthorized)
   236  			return
   237  		}
   238  
   239  		assert.Equal(t, expectedAuthorization, authorization)
   240  
   241  		responseTags := fakeTagsList{
   242  			Tags: []string{
   243  				"2.8.0",
   244  				"2.8.0-prerelease",
   245  				"2.8.0_build",
   246  				"2.8.0-prerelease_build",
   247  				"2.8.0-prerelease.1_build.1234",
   248  			},
   249  		}
   250  
   251  		w.Header().Set("Content-Type", "application/json")
   252  		w.WriteHeader(http.StatusOK)
   253  		require.NoError(t, json.NewEncoder(w).Encode(responseTags))
   254  	}))
   255  	t.Cleanup(server.Close)
   256  
   257  	serverURL, err := url.Parse(server.URL)
   258  	require.NoError(t, err)
   259  
   260  	testCases := []struct {
   261  		name    string
   262  		repoURL string
   263  	}{
   264  		{
   265  			name:    "should login correctly when the repo path is in the server root with http scheme",
   266  			repoURL: server.URL,
   267  		},
   268  		{
   269  			name:    "should login correctly when the repo path is not in the server root with http scheme",
   270  			repoURL: server.URL + "/my-repo",
   271  		},
   272  		{
   273  			name:    "should login correctly when the repo path is in the server root without http scheme",
   274  			repoURL: serverURL.Host,
   275  		},
   276  		{
   277  			name:    "should login correctly when the repo path is not in the server root without http scheme",
   278  			repoURL: serverURL.Host + "/my-repo",
   279  		},
   280  	}
   281  
   282  	for _, testCase := range testCases {
   283  		t.Run(testCase.name, func(t *testing.T) {
   284  			client := NewClient(testCase.repoURL, HelmCreds{
   285  				InsecureSkipVerify: true,
   286  				Username:           username,
   287  				Password:           password,
   288  			}, true, "", "")
   289  
   290  			tags, err := client.GetTags("mychart", true)
   291  
   292  			require.NoError(t, err)
   293  			assert.ElementsMatch(t, tags, []string{
   294  				"2.8.0",
   295  				"2.8.0-prerelease",
   296  				"2.8.0+build",
   297  				"2.8.0-prerelease+build",
   298  				"2.8.0-prerelease.1+build.1234",
   299  			})
   300  		})
   301  	}
   302  }
   303  
   304  func TestGetTagsFromURLPrivateRepoWithAzureWorkloadIdentityAuthentication(t *testing.T) {
   305  	expectedAuthorization := "Basic MDAwMDAwMDAtMDAwMC0wMDAwLTAwMDAtMDAwMDAwMDAwMDAwOmFjY2Vzc1Rva2Vu" // base64(00000000-0000-0000-0000-000000000000:accessToken)
   306  	mockServerURL := ""
   307  	mockedServerURL := func() string {
   308  		return mockServerURL
   309  	}
   310  
   311  	workloadIdentityMock := new(mocks.TokenProvider)
   312  	workloadIdentityMock.On("GetToken", "https://management.core.windows.net/.default").Return(&workloadidentity.Token{AccessToken: "accessToken"}, nil)
   313  
   314  	mockServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   315  		t.Logf("called %s", r.URL.Path)
   316  
   317  		switch r.URL.Path {
   318  		case "/v2/":
   319  			w.Header().Set("Www-Authenticate", fmt.Sprintf(`Bearer realm=%q,service=%q`, mockedServerURL(), mockedServerURL()[8:]))
   320  			w.WriteHeader(http.StatusUnauthorized)
   321  
   322  		case "/oauth2/exchange":
   323  			response := `{"refresh_token":"accessToken"}`
   324  			w.WriteHeader(http.StatusOK)
   325  			_, err := w.Write([]byte(response))
   326  			require.NoError(t, err)
   327  		default:
   328  			authorization := r.Header.Get("Authorization")
   329  
   330  			if authorization == "" {
   331  				w.Header().Set("WWW-Authenticate", `Basic realm="helm repo to get tags"`)
   332  				w.WriteHeader(http.StatusUnauthorized)
   333  				return
   334  			}
   335  
   336  			assert.Equal(t, expectedAuthorization, authorization)
   337  
   338  			responseTags := fakeTagsList{
   339  				Tags: []string{
   340  					"2.8.0",
   341  					"2.8.0-prerelease",
   342  					"2.8.0_build",
   343  					"2.8.0-prerelease_build",
   344  					"2.8.0-prerelease.1_build.1234",
   345  				},
   346  			}
   347  			w.Header().Set("Content-Type", "application/json")
   348  			w.WriteHeader(http.StatusOK)
   349  			require.NoError(t, json.NewEncoder(w).Encode(responseTags))
   350  		}
   351  	}))
   352  	mockServerURL = mockServer.URL
   353  	t.Cleanup(mockServer.Close)
   354  
   355  	serverURL, err := url.Parse(mockServer.URL)
   356  	require.NoError(t, err)
   357  
   358  	testCases := []struct {
   359  		name    string
   360  		repoURL string
   361  	}{
   362  		{
   363  			name:    "should login correctly when the repo path is in the server root with http scheme",
   364  			repoURL: mockServer.URL,
   365  		},
   366  		{
   367  			name:    "should login correctly when the repo path is not in the server root with http scheme",
   368  			repoURL: mockServer.URL + "/my-repo",
   369  		},
   370  		{
   371  			name:    "should login correctly when the repo path is in the server root without http scheme",
   372  			repoURL: serverURL.Host,
   373  		},
   374  		{
   375  			name:    "should login correctly when the repo path is not in the server root without http scheme",
   376  			repoURL: serverURL.Host + "/my-repo",
   377  		},
   378  	}
   379  
   380  	for _, testCase := range testCases {
   381  		t.Run(testCase.name, func(t *testing.T) {
   382  			client := NewClient(testCase.repoURL, AzureWorkloadIdentityCreds{
   383  				repoURL:            mockServer.URL[8:],
   384  				InsecureSkipVerify: true,
   385  				tokenProvider:      workloadIdentityMock,
   386  			}, true, "", "")
   387  
   388  			tags, err := client.GetTags("mychart", true)
   389  
   390  			require.NoError(t, err)
   391  			assert.ElementsMatch(t, tags, []string{
   392  				"2.8.0",
   393  				"2.8.0-prerelease",
   394  				"2.8.0+build",
   395  				"2.8.0-prerelease+build",
   396  				"2.8.0-prerelease.1+build.1234",
   397  			})
   398  		})
   399  	}
   400  }
   401  
   402  func TestGetTagsFromURLEnvironmentAuthentication(t *testing.T) {
   403  	bearerToken := "Zm9vOmJhcg=="
   404  	expectedAuthorization := "Basic " + bearerToken
   405  	server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   406  		t.Logf("called %s", r.URL.Path)
   407  
   408  		authorization := r.Header.Get("Authorization")
   409  		if authorization == "" {
   410  			w.Header().Set("WWW-Authenticate", `Basic realm="helm repo to get tags"`)
   411  			w.WriteHeader(http.StatusUnauthorized)
   412  			return
   413  		}
   414  
   415  		assert.Equal(t, expectedAuthorization, authorization)
   416  
   417  		responseTags := fakeTagsList{
   418  			Tags: []string{
   419  				"2.8.0",
   420  				"2.8.0-prerelease",
   421  				"2.8.0_build",
   422  				"2.8.0-prerelease_build",
   423  				"2.8.0-prerelease.1_build.1234",
   424  			},
   425  		}
   426  
   427  		w.Header().Set("Content-Type", "application/json")
   428  		w.WriteHeader(http.StatusOK)
   429  		require.NoError(t, json.NewEncoder(w).Encode(responseTags))
   430  	}))
   431  	t.Cleanup(server.Close)
   432  
   433  	serverURL, err := url.Parse(server.URL)
   434  	require.NoError(t, err)
   435  
   436  	tempDir := t.TempDir()
   437  	configPath := filepath.Join(tempDir, "config.json")
   438  	t.Setenv("DOCKER_CONFIG", tempDir)
   439  
   440  	config := fmt.Sprintf(`{"auths":{%q:{"auth":%q}}}`, server.URL, bearerToken)
   441  	require.NoError(t, os.WriteFile(configPath, []byte(config), 0o666))
   442  
   443  	testCases := []struct {
   444  		name    string
   445  		repoURL string
   446  	}{
   447  		{
   448  			name:    "should login correctly when the repo path is in the server root with http scheme",
   449  			repoURL: server.URL,
   450  		},
   451  		{
   452  			name:    "should login correctly when the repo path is not in the server root with http scheme",
   453  			repoURL: server.URL + "/my-repo",
   454  		},
   455  		{
   456  			name:    "should login correctly when the repo path is in the server root without http scheme",
   457  			repoURL: serverURL.Host,
   458  		},
   459  		{
   460  			name:    "should login correctly when the repo path is not in the server root without http scheme",
   461  			repoURL: serverURL.Host + "/my-repo",
   462  		},
   463  	}
   464  
   465  	for _, testCase := range testCases {
   466  		t.Run(testCase.name, func(t *testing.T) {
   467  			client := NewClient(testCase.repoURL, HelmCreds{
   468  				InsecureSkipVerify: true,
   469  			}, true, "", "")
   470  
   471  			tags, err := client.GetTags("mychart", true)
   472  
   473  			require.NoError(t, err)
   474  			assert.ElementsMatch(t, tags, []string{
   475  				"2.8.0",
   476  				"2.8.0-prerelease",
   477  				"2.8.0+build",
   478  				"2.8.0-prerelease+build",
   479  				"2.8.0-prerelease.1+build.1234",
   480  			})
   481  		})
   482  	}
   483  }
   484  
   485  func TestGetTagsCaching(t *testing.T) {
   486  	requestCount := 0
   487  	server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   488  		requestCount++
   489  		t.Logf("request %d called %s", requestCount, r.URL.Path)
   490  
   491  		responseTags := fakeTagsList{
   492  			Tags: []string{
   493  				"1.0.0",
   494  				"1.1.0",
   495  				"2.0.0_beta",
   496  			},
   497  		}
   498  
   499  		w.Header().Set("Content-Type", "application/json")
   500  		w.WriteHeader(http.StatusOK)
   501  		require.NoError(t, json.NewEncoder(w).Encode(responseTags))
   502  	}))
   503  	t.Cleanup(server.Close)
   504  
   505  	serverURL, err := url.Parse(server.URL)
   506  	require.NoError(t, err)
   507  
   508  	t.Run("should cache tags correctly", func(t *testing.T) {
   509  		cache := &fakeIndexCache{}
   510  		client := NewClient(serverURL.Host, HelmCreds{
   511  			InsecureSkipVerify: true,
   512  		}, true, "", "", WithIndexCache(cache))
   513  
   514  		tags1, err := client.GetTags("mychart", false)
   515  		require.NoError(t, err)
   516  		assert.ElementsMatch(t, tags1, []string{
   517  			"1.0.0",
   518  			"1.1.0",
   519  			"2.0.0+beta",
   520  		})
   521  		assert.Equal(t, 1, requestCount)
   522  
   523  		requestCount = 0
   524  
   525  		tags2, err := client.GetTags("mychart", false)
   526  		require.NoError(t, err)
   527  		assert.ElementsMatch(t, tags2, []string{
   528  			"1.0.0",
   529  			"1.1.0",
   530  			"2.0.0+beta",
   531  		})
   532  		assert.Equal(t, 0, requestCount)
   533  
   534  		assert.NotEmpty(t, cache.data)
   535  
   536  		type entriesStruct struct {
   537  			Tags []string
   538  		}
   539  		var entries entriesStruct
   540  		err = json.Unmarshal(cache.data, &entries)
   541  		require.NoError(t, err)
   542  		assert.ElementsMatch(t, entries.Tags, []string{
   543  			"1.0.0",
   544  			"1.1.0",
   545  			"2.0.0+beta",
   546  		})
   547  	})
   548  
   549  	t.Run("should bypass cache when noCache is true", func(t *testing.T) {
   550  		cache := &fakeIndexCache{}
   551  		client := NewClient(serverURL.Host, HelmCreds{
   552  			InsecureSkipVerify: true,
   553  		}, true, "", "", WithIndexCache(cache))
   554  
   555  		requestCount = 0
   556  
   557  		tags1, err := client.GetTags("mychart", true)
   558  		require.NoError(t, err)
   559  		assert.ElementsMatch(t, tags1, []string{
   560  			"1.0.0",
   561  			"1.1.0",
   562  			"2.0.0+beta",
   563  		})
   564  		assert.Equal(t, 1, requestCount)
   565  
   566  		tags2, err := client.GetTags("mychart", true)
   567  		require.NoError(t, err)
   568  		assert.ElementsMatch(t, tags2, []string{
   569  			"1.0.0",
   570  			"1.1.0",
   571  			"2.0.0+beta",
   572  		})
   573  		assert.Equal(t, 2, requestCount)
   574  	})
   575  }