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 }