github.com/argoproj/argo-cd/v3@v3.2.1/util/webhook/webhook_test.go (about) 1 package webhook 2 3 import ( 4 "bytes" 5 "context" 6 "encoding/json" 7 "errors" 8 "fmt" 9 "io" 10 "net/http" 11 "net/http/httptest" 12 "os" 13 "strings" 14 "testing" 15 "text/template" 16 "time" 17 18 "github.com/go-playground/webhooks/v6/azuredevops" 19 20 bb "github.com/ktrysmt/go-bitbucket" 21 "github.com/stretchr/testify/mock" 22 "k8s.io/apimachinery/pkg/labels" 23 "k8s.io/apimachinery/pkg/types" 24 25 "github.com/go-playground/webhooks/v6/bitbucket" 26 bitbucketserver "github.com/go-playground/webhooks/v6/bitbucket-server" 27 "github.com/go-playground/webhooks/v6/github" 28 "github.com/go-playground/webhooks/v6/gitlab" 29 gogsclient "github.com/gogits/go-gogs-client" 30 "github.com/jarcoal/httpmock" 31 "k8s.io/apimachinery/pkg/runtime" 32 kubetesting "k8s.io/client-go/testing" 33 34 argov1 "github.com/argoproj/argo-cd/v3/pkg/client/listers/application/v1alpha1" 35 servercache "github.com/argoproj/argo-cd/v3/server/cache" 36 "github.com/argoproj/argo-cd/v3/util/cache/appstate" 37 "github.com/argoproj/argo-cd/v3/util/db" 38 "github.com/argoproj/argo-cd/v3/util/db/mocks" 39 40 "github.com/sirupsen/logrus/hooks/test" 41 "github.com/stretchr/testify/assert" 42 "github.com/stretchr/testify/require" 43 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 44 45 "github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1" 46 appclientset "github.com/argoproj/argo-cd/v3/pkg/client/clientset/versioned/fake" 47 "github.com/argoproj/argo-cd/v3/reposerver/cache" 48 cacheutil "github.com/argoproj/argo-cd/v3/util/cache" 49 "github.com/argoproj/argo-cd/v3/util/settings" 50 ) 51 52 type fakeSettingsSrc struct{} 53 54 func (f fakeSettingsSrc) GetAppInstanceLabelKey() (string, error) { 55 return "mycompany.com/appname", nil 56 } 57 58 func (f fakeSettingsSrc) GetTrackingMethod() (string, error) { 59 return "", nil 60 } 61 62 func (f fakeSettingsSrc) GetInstallationID() (string, error) { 63 return "", nil 64 } 65 66 type reactorDef struct { 67 verb string 68 resource string 69 reaction kubetesting.ReactionFunc 70 } 71 72 func NewMockHandler(reactor *reactorDef, applicationNamespaces []string, objects ...runtime.Object) *ArgoCDWebhookHandler { 73 defaultMaxPayloadSize := int64(50) * 1024 * 1024 74 return NewMockHandlerWithPayloadLimit(reactor, applicationNamespaces, defaultMaxPayloadSize, objects...) 75 } 76 77 func NewMockHandlerWithPayloadLimit(reactor *reactorDef, applicationNamespaces []string, maxPayloadSize int64, objects ...runtime.Object) *ArgoCDWebhookHandler { 78 return newMockHandler(reactor, applicationNamespaces, maxPayloadSize, &mocks.ArgoDB{}, &settings.ArgoCDSettings{}, objects...) 79 } 80 81 func NewMockHandlerForBitbucketCallback(reactor *reactorDef, applicationNamespaces []string, objects ...runtime.Object) *ArgoCDWebhookHandler { 82 mockDB := mocks.ArgoDB{} 83 mockDB.On("ListRepositories", mock.Anything).Return( 84 []*v1alpha1.Repository{ 85 { 86 Repo: "https://bitbucket.org/test/argocd-examples-pub.git", 87 Username: "test", 88 Password: "test", 89 }, 90 { 91 Repo: "https://bitbucket.org/test-owner/test-repo.git", 92 Username: "test", 93 Password: "test", 94 }, 95 { 96 Repo: "git@bitbucket.org:test/argocd-examples-pub.git", 97 SSHPrivateKey: "test-ssh-key", 98 }, 99 }, nil) 100 argoSettings := settings.ArgoCDSettings{WebhookBitbucketUUID: "abcd-efgh-ijkl-mnop"} 101 defaultMaxPayloadSize := int64(50) * 1024 * 1024 102 return newMockHandler(reactor, applicationNamespaces, defaultMaxPayloadSize, &mockDB, &argoSettings, objects...) 103 } 104 105 type fakeAppsLister struct { 106 argov1.ApplicationLister 107 argov1.ApplicationNamespaceLister 108 namespace string 109 clientset *appclientset.Clientset 110 } 111 112 func (f *fakeAppsLister) Applications(namespace string) argov1.ApplicationNamespaceLister { 113 return &fakeAppsLister{namespace: namespace, clientset: f.clientset} 114 } 115 116 func (f *fakeAppsLister) List(selector labels.Selector) ([]*v1alpha1.Application, error) { 117 res, err := f.clientset.ArgoprojV1alpha1().Applications(f.namespace).List(context.Background(), metav1.ListOptions{ 118 LabelSelector: selector.String(), 119 }) 120 if err != nil { 121 return nil, err 122 } 123 var apps []*v1alpha1.Application 124 for i := range res.Items { 125 apps = append(apps, &res.Items[i]) 126 } 127 return apps, nil 128 } 129 130 func newMockHandler(reactor *reactorDef, applicationNamespaces []string, maxPayloadSize int64, argoDB db.ArgoDB, argoSettings *settings.ArgoCDSettings, objects ...runtime.Object) *ArgoCDWebhookHandler { 131 appClientset := appclientset.NewSimpleClientset(objects...) 132 if reactor != nil { 133 defaultReactor := appClientset.ReactionChain[0] 134 appClientset.ReactionChain = nil 135 appClientset.AddReactor("list", "*", func(action kubetesting.Action) (handled bool, ret runtime.Object, err error) { 136 return defaultReactor.React(action) 137 }) 138 appClientset.AddReactor(reactor.verb, reactor.resource, reactor.reaction) 139 } 140 cacheClient := cacheutil.NewCache(cacheutil.NewInMemoryCache(1 * time.Hour)) 141 return NewHandler("argocd", applicationNamespaces, 10, appClientset, &fakeAppsLister{clientset: appClientset}, argoSettings, &fakeSettingsSrc{}, cache.NewCache( 142 cacheClient, 143 1*time.Minute, 144 1*time.Minute, 145 10*time.Second, 146 ), servercache.NewCache(appstate.NewCache(cacheClient, time.Minute), time.Minute, time.Minute), argoDB, maxPayloadSize) 147 } 148 149 func TestGitHubCommitEvent(t *testing.T) { 150 hook := test.NewGlobal() 151 h := NewMockHandler(nil, []string{}) 152 req := httptest.NewRequest(http.MethodPost, "/api/webhook", http.NoBody) 153 req.Header.Set("X-GitHub-Event", "push") 154 eventJSON, err := os.ReadFile("testdata/github-commit-event.json") 155 require.NoError(t, err) 156 req.Body = io.NopCloser(bytes.NewReader(eventJSON)) 157 w := httptest.NewRecorder() 158 h.Handler(w, req) 159 close(h.queue) 160 h.Wait() 161 assert.Equal(t, http.StatusOK, w.Code) 162 expectedLogResult := "Received push event repo: https://github.com/jessesuen/test-repo, revision: master, touchedHead: true" 163 assert.Equal(t, expectedLogResult, hook.LastEntry().Message) 164 hook.Reset() 165 } 166 167 func TestAzureDevOpsCommitEvent(t *testing.T) { 168 hook := test.NewGlobal() 169 h := NewMockHandler(nil, []string{}) 170 req := httptest.NewRequest(http.MethodPost, "/api/webhook", http.NoBody) 171 req.Header.Set("X-Vss-Activityid", "abc") 172 eventJSON, err := os.ReadFile("testdata/azuredevops-git-push-event.json") 173 require.NoError(t, err) 174 req.Body = io.NopCloser(bytes.NewReader(eventJSON)) 175 w := httptest.NewRecorder() 176 h.Handler(w, req) 177 close(h.queue) 178 h.Wait() 179 assert.Equal(t, http.StatusOK, w.Code) 180 expectedLogResult := "Received push event repo: https://dev.azure.com/alexander0053/alex-test/_git/alex-test, revision: master, touchedHead: true" 181 assert.Equal(t, expectedLogResult, hook.LastEntry().Message) 182 hook.Reset() 183 } 184 185 // TestGitHubCommitEvent_MultiSource_Refresh makes sure that a webhook will refresh a multi-source app when at least 186 // one source matches. 187 func TestGitHubCommitEvent_MultiSource_Refresh(t *testing.T) { 188 hook := test.NewGlobal() 189 var patched bool 190 reaction := func(action kubetesting.Action) (handled bool, ret runtime.Object, err error) { 191 patchAction := action.(kubetesting.PatchAction) 192 assert.Equal(t, "app-to-refresh", patchAction.GetName()) 193 patched = true 194 return true, nil, nil 195 } 196 h := NewMockHandler(&reactorDef{"patch", "applications", reaction}, []string{}, &v1alpha1.Application{ 197 ObjectMeta: metav1.ObjectMeta{ 198 Name: "app-to-refresh", 199 Namespace: "argocd", 200 }, 201 Spec: v1alpha1.ApplicationSpec{ 202 Sources: v1alpha1.ApplicationSources{ 203 { 204 RepoURL: "https://github.com/some/unrelated-repo", 205 Path: ".", 206 }, 207 { 208 RepoURL: "https://github.com/jessesuen/test-repo", 209 Path: ".", 210 }, 211 }, 212 }, 213 }, &v1alpha1.Application{ 214 ObjectMeta: metav1.ObjectMeta{ 215 Name: "app-to-ignore", 216 }, 217 Spec: v1alpha1.ApplicationSpec{ 218 Sources: v1alpha1.ApplicationSources{ 219 { 220 RepoURL: "https://github.com/some/unrelated-repo", 221 Path: ".", 222 }, 223 }, 224 }, 225 }, 226 ) 227 req := httptest.NewRequest(http.MethodPost, "/api/webhook", http.NoBody) 228 req.Header.Set("X-GitHub-Event", "push") 229 eventJSON, err := os.ReadFile("testdata/github-commit-event.json") 230 require.NoError(t, err) 231 req.Body = io.NopCloser(bytes.NewReader(eventJSON)) 232 w := httptest.NewRecorder() 233 h.Handler(w, req) 234 close(h.queue) 235 h.Wait() 236 assert.Equal(t, http.StatusOK, w.Code) 237 expectedLogResult := "Requested app 'app-to-refresh' refresh" 238 assert.Equal(t, expectedLogResult, hook.LastEntry().Message) 239 assert.True(t, patched) 240 hook.Reset() 241 } 242 243 // TestGitHubCommitEvent_AppsInOtherNamespaces makes sure that webhooks properly find apps in the configured set of 244 // allowed namespaces when Apps are allowed in any namespace 245 func TestGitHubCommitEvent_AppsInOtherNamespaces(t *testing.T) { 246 hook := test.NewGlobal() 247 248 patchedApps := make([]types.NamespacedName, 0, 3) 249 reaction := func(action kubetesting.Action) (handled bool, ret runtime.Object, err error) { 250 patchAction := action.(kubetesting.PatchAction) 251 patchedApps = append(patchedApps, types.NamespacedName{Name: patchAction.GetName(), Namespace: patchAction.GetNamespace()}) 252 return true, nil, nil 253 } 254 255 h := NewMockHandler(&reactorDef{"patch", "applications", reaction}, []string{"end-to-end-tests", "app-team-*"}, 256 &v1alpha1.Application{ 257 ObjectMeta: metav1.ObjectMeta{ 258 Name: "app-to-refresh-in-default-namespace", 259 Namespace: "argocd", 260 }, 261 Spec: v1alpha1.ApplicationSpec{ 262 Sources: v1alpha1.ApplicationSources{ 263 { 264 RepoURL: "https://github.com/jessesuen/test-repo", 265 Path: ".", 266 }, 267 }, 268 }, 269 }, &v1alpha1.Application{ 270 ObjectMeta: metav1.ObjectMeta{ 271 Name: "app-to-ignore", 272 Namespace: "kube-system", 273 }, 274 Spec: v1alpha1.ApplicationSpec{ 275 Sources: v1alpha1.ApplicationSources{ 276 { 277 RepoURL: "https://github.com/jessesuen/test-repo", 278 Path: ".", 279 }, 280 }, 281 }, 282 }, &v1alpha1.Application{ 283 ObjectMeta: metav1.ObjectMeta{ 284 Name: "app-to-refresh-in-exact-match-namespace", 285 Namespace: "end-to-end-tests", 286 }, 287 Spec: v1alpha1.ApplicationSpec{ 288 Sources: v1alpha1.ApplicationSources{ 289 { 290 RepoURL: "https://github.com/jessesuen/test-repo", 291 Path: ".", 292 }, 293 }, 294 }, 295 }, &v1alpha1.Application{ 296 ObjectMeta: metav1.ObjectMeta{ 297 Name: "app-to-refresh-in-globbed-namespace", 298 Namespace: "app-team-two", 299 }, 300 Spec: v1alpha1.ApplicationSpec{ 301 Sources: v1alpha1.ApplicationSources{ 302 { 303 RepoURL: "https://github.com/jessesuen/test-repo", 304 Path: ".", 305 }, 306 }, 307 }, 308 }, 309 ) 310 req := httptest.NewRequest(http.MethodPost, "/api/webhook", http.NoBody) 311 req.Header.Set("X-GitHub-Event", "push") 312 eventJSON, err := os.ReadFile("testdata/github-commit-event.json") 313 require.NoError(t, err) 314 req.Body = io.NopCloser(bytes.NewReader(eventJSON)) 315 w := httptest.NewRecorder() 316 h.Handler(w, req) 317 close(h.queue) 318 h.Wait() 319 assert.Equal(t, http.StatusOK, w.Code) 320 321 logMessages := make([]string, 0, len(hook.Entries)) 322 323 for _, entry := range hook.Entries { 324 logMessages = append(logMessages, entry.Message) 325 } 326 327 assert.Contains(t, logMessages, "Requested app 'app-to-refresh-in-default-namespace' refresh") 328 assert.Contains(t, logMessages, "Requested app 'app-to-refresh-in-exact-match-namespace' refresh") 329 assert.Contains(t, logMessages, "Requested app 'app-to-refresh-in-globbed-namespace' refresh") 330 assert.NotContains(t, logMessages, "Requested app 'app-to-ignore' refresh") 331 332 assert.Contains(t, patchedApps, types.NamespacedName{Name: "app-to-refresh-in-default-namespace", Namespace: "argocd"}) 333 assert.Contains(t, patchedApps, types.NamespacedName{Name: "app-to-refresh-in-exact-match-namespace", Namespace: "end-to-end-tests"}) 334 assert.Contains(t, patchedApps, types.NamespacedName{Name: "app-to-refresh-in-globbed-namespace", Namespace: "app-team-two"}) 335 assert.NotContains(t, patchedApps, types.NamespacedName{Name: "app-to-ignore", Namespace: "kube-system"}) 336 assert.Len(t, patchedApps, 3) 337 338 hook.Reset() 339 } 340 341 // TestGitHubCommitEvent_Hydrate makes sure that a webhook will hydrate an app when dry source changed. 342 func TestGitHubCommitEvent_Hydrate(t *testing.T) { 343 hook := test.NewGlobal() 344 var patched bool 345 reaction := func(action kubetesting.Action) (handled bool, ret runtime.Object, err error) { 346 patchAction := action.(kubetesting.PatchAction) 347 assert.Equal(t, "app-to-hydrate", patchAction.GetName()) 348 patched = true 349 return true, nil, nil 350 } 351 h := NewMockHandler(&reactorDef{"patch", "applications", reaction}, []string{}, &v1alpha1.Application{ 352 ObjectMeta: metav1.ObjectMeta{ 353 Name: "app-to-hydrate", 354 Namespace: "argocd", 355 }, 356 Spec: v1alpha1.ApplicationSpec{ 357 SourceHydrator: &v1alpha1.SourceHydrator{ 358 DrySource: v1alpha1.DrySource{ 359 RepoURL: "https://github.com/jessesuen/test-repo", 360 TargetRevision: "HEAD", 361 Path: ".", 362 }, 363 SyncSource: v1alpha1.SyncSource{ 364 TargetBranch: "environments/dev", 365 Path: ".", 366 }, 367 HydrateTo: nil, 368 }, 369 }, 370 }, &v1alpha1.Application{ 371 ObjectMeta: metav1.ObjectMeta{ 372 Name: "app-to-ignore", 373 }, 374 Spec: v1alpha1.ApplicationSpec{ 375 Sources: v1alpha1.ApplicationSources{ 376 { 377 RepoURL: "https://github.com/some/unrelated-repo", 378 Path: ".", 379 }, 380 }, 381 }, 382 }, 383 ) 384 req := httptest.NewRequest(http.MethodPost, "/api/webhook", http.NoBody) 385 req.Header.Set("X-GitHub-Event", "push") 386 eventJSON, err := os.ReadFile("testdata/github-commit-event.json") 387 require.NoError(t, err) 388 req.Body = io.NopCloser(bytes.NewReader(eventJSON)) 389 w := httptest.NewRecorder() 390 h.Handler(w, req) 391 close(h.queue) 392 h.Wait() 393 assert.Equal(t, http.StatusOK, w.Code) 394 assert.True(t, patched) 395 396 logMessages := make([]string, 0, len(hook.Entries)) 397 for _, entry := range hook.Entries { 398 logMessages = append(logMessages, entry.Message) 399 } 400 401 assert.Contains(t, logMessages, "webhook trigger refresh app to hydrate 'app-to-hydrate'") 402 assert.NotContains(t, logMessages, "webhook trigger refresh app to hydrate 'app-to-ignore'") 403 404 hook.Reset() 405 } 406 407 func TestGitHubTagEvent(t *testing.T) { 408 hook := test.NewGlobal() 409 h := NewMockHandler(nil, []string{}) 410 req := httptest.NewRequest(http.MethodPost, "/api/webhook", http.NoBody) 411 req.Header.Set("X-GitHub-Event", "push") 412 eventJSON, err := os.ReadFile("testdata/github-tag-event.json") 413 require.NoError(t, err) 414 req.Body = io.NopCloser(bytes.NewReader(eventJSON)) 415 w := httptest.NewRecorder() 416 h.Handler(w, req) 417 close(h.queue) 418 h.Wait() 419 assert.Equal(t, http.StatusOK, w.Code) 420 expectedLogResult := "Received push event repo: https://github.com/jessesuen/test-repo, revision: v1.0, touchedHead: false" 421 assert.Equal(t, expectedLogResult, hook.LastEntry().Message) 422 hook.Reset() 423 } 424 425 func TestGitHubPingEvent(t *testing.T) { 426 hook := test.NewGlobal() 427 h := NewMockHandler(nil, []string{}) 428 req := httptest.NewRequest(http.MethodPost, "/api/webhook", http.NoBody) 429 req.Header.Set("X-GitHub-Event", "ping") 430 eventJSON, err := os.ReadFile("testdata/github-ping-event.json") 431 require.NoError(t, err) 432 req.Body = io.NopCloser(bytes.NewReader(eventJSON)) 433 w := httptest.NewRecorder() 434 h.Handler(w, req) 435 close(h.queue) 436 h.Wait() 437 assert.Equal(t, http.StatusOK, w.Code) 438 expectedLogResult := "Ignoring webhook event" 439 assert.Equal(t, expectedLogResult, hook.LastEntry().Message) 440 hook.Reset() 441 } 442 443 func TestBitbucketServerRepositoryReferenceChangedEvent(t *testing.T) { 444 hook := test.NewGlobal() 445 h := NewMockHandler(nil, []string{}) 446 req := httptest.NewRequest(http.MethodPost, "/api/webhook", http.NoBody) 447 req.Header.Set("X-Event-Key", "repo:refs_changed") 448 eventJSON, err := os.ReadFile("testdata/bitbucket-server-event.json") 449 require.NoError(t, err) 450 req.Body = io.NopCloser(bytes.NewReader(eventJSON)) 451 w := httptest.NewRecorder() 452 h.Handler(w, req) 453 close(h.queue) 454 h.Wait() 455 assert.Equal(t, http.StatusOK, w.Code) 456 expectedLogResultSSH := "Received push event repo: ssh://git@bitbucketserver:7999/myproject/test-repo.git, revision: master, touchedHead: true" 457 assert.Equal(t, expectedLogResultSSH, hook.AllEntries()[len(hook.AllEntries())-2].Message) 458 expectedLogResultHTTPS := "Received push event repo: https://bitbucketserver/scm/myproject/test-repo.git, revision: master, touchedHead: true" 459 assert.Equal(t, expectedLogResultHTTPS, hook.LastEntry().Message) 460 hook.Reset() 461 } 462 463 func TestBitbucketServerRepositoryDiagnosticPingEvent(t *testing.T) { 464 hook := test.NewGlobal() 465 h := NewMockHandler(nil, []string{}) 466 eventJSON := "{\"test\": true}" 467 req := httptest.NewRequest(http.MethodPost, "/api/webhook", bytes.NewBufferString(eventJSON)) 468 req.Header.Set("X-Event-Key", "diagnostics:ping") 469 w := httptest.NewRecorder() 470 h.Handler(w, req) 471 close(h.queue) 472 h.Wait() 473 assert.Equal(t, http.StatusOK, w.Code) 474 expectedLogResult := "Ignoring webhook event" 475 assert.Equal(t, expectedLogResult, hook.LastEntry().Message) 476 hook.Reset() 477 } 478 479 func TestGogsPushEvent(t *testing.T) { 480 hook := test.NewGlobal() 481 h := NewMockHandler(nil, []string{}) 482 req := httptest.NewRequest(http.MethodPost, "/api/webhook", http.NoBody) 483 req.Header.Set("X-Gogs-Event", "push") 484 eventJSON, err := os.ReadFile("testdata/gogs-event.json") 485 require.NoError(t, err) 486 req.Body = io.NopCloser(bytes.NewReader(eventJSON)) 487 w := httptest.NewRecorder() 488 h.Handler(w, req) 489 close(h.queue) 490 h.Wait() 491 assert.Equal(t, http.StatusOK, w.Code) 492 expectedLogResult := "Received push event repo: http://gogs-server/john/repo-test, revision: master, touchedHead: true" 493 assert.Equal(t, expectedLogResult, hook.LastEntry().Message) 494 hook.Reset() 495 } 496 497 func TestGitLabPushEvent(t *testing.T) { 498 hook := test.NewGlobal() 499 h := NewMockHandler(nil, []string{}) 500 req := httptest.NewRequest(http.MethodPost, "/api/webhook", http.NoBody) 501 req.Header.Set("X-Gitlab-Event", "Push Hook") 502 eventJSON, err := os.ReadFile("testdata/gitlab-event.json") 503 require.NoError(t, err) 504 req.Body = io.NopCloser(bytes.NewReader(eventJSON)) 505 w := httptest.NewRecorder() 506 h.Handler(w, req) 507 close(h.queue) 508 h.Wait() 509 assert.Equal(t, http.StatusOK, w.Code) 510 expectedLogResult := "Received push event repo: https://gitlab.com/group/name, revision: master, touchedHead: true" 511 assert.Equal(t, expectedLogResult, hook.LastEntry().Message) 512 hook.Reset() 513 } 514 515 func TestGitLabSystemEvent(t *testing.T) { 516 hook := test.NewGlobal() 517 h := NewMockHandler(nil, []string{}) 518 req := httptest.NewRequest(http.MethodPost, "/api/webhook", http.NoBody) 519 req.Header.Set("X-Gitlab-Event", "System Hook") 520 eventJSON, err := os.ReadFile("testdata/gitlab-event.json") 521 require.NoError(t, err) 522 req.Body = io.NopCloser(bytes.NewReader(eventJSON)) 523 w := httptest.NewRecorder() 524 h.Handler(w, req) 525 close(h.queue) 526 h.Wait() 527 assert.Equal(t, http.StatusOK, w.Code) 528 expectedLogResult := "Received push event repo: https://gitlab.com/group/name, revision: master, touchedHead: true" 529 assert.Equal(t, expectedLogResult, hook.LastEntry().Message) 530 hook.Reset() 531 } 532 533 func TestInvalidMethod(t *testing.T) { 534 hook := test.NewGlobal() 535 h := NewMockHandler(nil, []string{}) 536 req := httptest.NewRequest(http.MethodGet, "/api/webhook", http.NoBody) 537 req.Header.Set("X-GitHub-Event", "push") 538 w := httptest.NewRecorder() 539 h.Handler(w, req) 540 close(h.queue) 541 h.Wait() 542 assert.Equal(t, http.StatusMethodNotAllowed, w.Code) 543 expectedLogResult := "Webhook processing failed: invalid HTTP Method" 544 assert.Equal(t, expectedLogResult, hook.LastEntry().Message) 545 assert.Equal(t, expectedLogResult+"\n", w.Body.String()) 546 hook.Reset() 547 } 548 549 func TestInvalidEvent(t *testing.T) { 550 hook := test.NewGlobal() 551 h := NewMockHandler(nil, []string{}) 552 req := httptest.NewRequest(http.MethodPost, "/api/webhook", http.NoBody) 553 req.Header.Set("X-GitHub-Event", "push") 554 w := httptest.NewRecorder() 555 h.Handler(w, req) 556 close(h.queue) 557 h.Wait() 558 assert.Equal(t, http.StatusBadRequest, w.Code) 559 expectedLogResult := "Webhook processing failed: The payload is either too large or corrupted. Please check the payload size (must be under 50 MB) and ensure it is valid JSON" 560 assert.Equal(t, expectedLogResult, hook.LastEntry().Message) 561 assert.Equal(t, expectedLogResult+"\n", w.Body.String()) 562 hook.Reset() 563 } 564 565 func TestUnknownEvent(t *testing.T) { 566 hook := test.NewGlobal() 567 h := NewMockHandler(nil, []string{}) 568 req := httptest.NewRequest(http.MethodPost, "/api/webhook", http.NoBody) 569 req.Header.Set("X-Unknown-Event", "push") 570 w := httptest.NewRecorder() 571 h.Handler(w, req) 572 close(h.queue) 573 h.Wait() 574 assert.Equal(t, http.StatusBadRequest, w.Code) 575 assert.Equal(t, "Unknown webhook event\n", w.Body.String()) 576 hook.Reset() 577 } 578 579 func TestAppRevisionHasChanged(t *testing.T) { 580 t.Parallel() 581 582 getSource := func(targetRevision string) v1alpha1.ApplicationSource { 583 return v1alpha1.ApplicationSource{TargetRevision: targetRevision} 584 } 585 586 testCases := []struct { 587 name string 588 source v1alpha1.ApplicationSource 589 revision string 590 touchedHead bool 591 expectHasChanged bool 592 }{ 593 {"no target revision, master, touched head", getSource(""), "master", true, true}, 594 {"no target revision, master, did not touch head", getSource(""), "master", false, false}, 595 {"dev target revision, master, touched head", getSource("dev"), "master", true, false}, 596 {"dev target revision, dev, did not touch head", getSource("dev"), "dev", false, true}, 597 {"refs/heads/dev target revision, master, touched head", getSource("refs/heads/dev"), "master", true, false}, 598 {"refs/heads/dev target revision, dev, did not touch head", getSource("refs/heads/dev"), "dev", false, true}, 599 {"refs/tags/dev target revision, dev, did not touch head", getSource("refs/tags/dev"), "dev", false, true}, 600 {"env/test target revision, env/test, did not touch head", getSource("env/test"), "env/test", false, true}, 601 {"refs/heads/env/test target revision, env/test, did not touch head", getSource("refs/heads/env/test"), "env/test", false, true}, 602 {"refs/tags/env/test target revision, env/test, did not touch head", getSource("refs/tags/env/test"), "env/test", false, true}, 603 {"three/part/rev target revision, rev, did not touch head", getSource("three/part/rev"), "rev", false, false}, 604 {"1.* target revision (matching), 1.1.0, did not touch head", getSource("1.*"), "1.1.0", false, true}, 605 {"refs/tags/1.* target revision (matching), 1.1.0, did not touch head", getSource("refs/tags/1.*"), "1.1.0", false, true}, 606 {"1.* target revision (not matching), 2.0.0, did not touch head", getSource("1.*"), "2.0.0", false, false}, 607 {"1.* target revision, dev (not semver), did not touch head", getSource("1.*"), "dev", false, false}, 608 } 609 610 for _, tc := range testCases { 611 tcc := tc 612 t.Run(tcc.name, func(t *testing.T) { 613 t.Parallel() 614 changed := sourceRevisionHasChanged(tcc.source, tcc.revision, tcc.touchedHead) 615 assert.Equal(t, tcc.expectHasChanged, changed) 616 }) 617 } 618 } 619 620 func Test_affectedRevisionInfo_appRevisionHasChanged(t *testing.T) { 621 t.Parallel() 622 623 sourceWithRevision := func(targetRevision string) v1alpha1.ApplicationSource { 624 return v1alpha1.ApplicationSource{TargetRevision: targetRevision} 625 } 626 627 githubPushPayload := func(branchName string) github.PushPayload { 628 // This payload's "ref" member always has the full git ref, according to the field description. 629 // https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#push 630 return github.PushPayload{Ref: "refs/heads/" + branchName} 631 } 632 633 gitlabPushPayload := func(branchName string) gitlab.PushEventPayload { 634 // This payload's "ref" member seems to always have the full git ref (based on the example payload). 635 // https://docs.gitlab.com/ee/user/project/integrations/webhook_events.html#push-events 636 return gitlab.PushEventPayload{Ref: "refs/heads/" + branchName} 637 } 638 639 gitlabTagPayload := func(tagName string) gitlab.TagEventPayload { 640 // This payload's "ref" member seems to always have the full git ref (based on the example payload). 641 // https://docs.gitlab.com/ee/user/project/integrations/webhook_events.html#tag-events 642 return gitlab.TagEventPayload{Ref: "refs/tags/" + tagName} 643 } 644 645 bitbucketPushPayload := func(branchName string) bitbucket.RepoPushPayload { 646 // The payload's "push.changes[0].new.name" member seems to only have the branch name (based on the example payload). 647 // https://support.atlassian.com/bitbucket-cloud/docs/event-payloads/#EventPayloads-Push 648 var pl bitbucket.RepoPushPayload 649 _ = json.Unmarshal([]byte(fmt.Sprintf(`{"push":{"changes":[{"new":{"name":%q}}]}}`, branchName)), &pl) 650 return pl 651 } 652 653 bitbucketRefChangedPayload := func(branchName string) bitbucketserver.RepositoryReferenceChangedPayload { 654 // This payload's "changes[0].ref.id" member seems to always have the full git ref (based on the example payload). 655 // https://confluence.atlassian.com/bitbucketserver/event-payload-938025882.html#Eventpayload-Push 656 return bitbucketserver.RepositoryReferenceChangedPayload{ 657 Changes: []bitbucketserver.RepositoryChange{ 658 {Reference: bitbucketserver.RepositoryReference{ID: "refs/heads/" + branchName}}, 659 }, 660 Repository: bitbucketserver.Repository{Links: map[string]any{"clone": []any{}}}, 661 } 662 } 663 664 gogsPushPayload := func(branchName string) gogsclient.PushPayload { 665 // This payload's "ref" member seems to always have the full git ref (based on the example payload). 666 // https://gogs.io/docs/features/webhook#event-information 667 return gogsclient.PushPayload{Ref: "refs/heads/" + branchName, Repo: &gogsclient.Repository{}} 668 } 669 670 tests := []struct { 671 hasChanged bool 672 targetRevision string 673 hookPayload any 674 name string 675 }{ 676 // Edge cases for bitbucket. 677 // Bitbucket push events just have tag or branch names instead of fully-qualified refs. If someone were to create 678 // a branch starting with refs/heads/ or refs/tags/, they couldn't use the branch name in targetRevision. 679 {false, "refs/heads/x", bitbucketPushPayload("refs/heads/x"), "bitbucket branch name containing 'refs/heads/'"}, 680 {false, "refs/tags/x", bitbucketPushPayload("refs/tags/x"), "bitbucket branch name containing 'refs/tags/'"}, 681 {false, "x", bitbucketPushPayload("refs/heads/x"), "bitbucket branch name containing 'refs/heads/', targetRevision with just the part after refs/heads/"}, 682 {false, "x", bitbucketPushPayload("refs/tags/x"), "bitbucket branch name containing 'refs/tags/', targetRevision with just the part after refs/tags/"}, 683 // However, a targetRevision prefixed with refs/heads/ or refs/tags/ would match a payload with just the suffix. 684 {true, "refs/heads/x", bitbucketPushPayload("x"), "bitbucket branch name containing 'refs/heads/', targetRevision with just the part after refs/heads/"}, 685 {true, "refs/tags/x", bitbucketPushPayload("x"), "bitbucket branch name containing 'refs/tags/', targetRevision with just the part after refs/tags/"}, 686 // They could also hack around the issue by prepending another refs/heads/ 687 {true, "refs/heads/refs/heads/x", bitbucketPushPayload("refs/heads/x"), "bitbucket branch name containing 'refs/heads/'"}, 688 {true, "refs/heads/refs/tags/x", bitbucketPushPayload("refs/tags/x"), "bitbucket branch name containing 'refs/tags/'"}, 689 690 // Standard cases. These tests show that 691 // 1) Slashes in branch names do not cause missed refreshes. 692 // 2) Fully-qualifying branches/tags by adding the refs/(heads|tags)/ prefix does not cause missed refreshes. 693 // 3) Branches and tags are not differentiated. A branch event with branch name 'x' will match all the following: 694 // a. targetRevision: x 695 // b. targetRevision: refs/heads/x 696 // c. targetRevision: refs/tags/x 697 // A tag event with tag name 'x' will match all of those as well. 698 699 {true, "has/slashes", githubPushPayload("has/slashes"), "github push branch name with slashes, targetRevision not prefixed"}, 700 {true, "has/slashes", gitlabPushPayload("has/slashes"), "gitlab push branch name with slashes, targetRevision not prefixed"}, 701 {true, "has/slashes", bitbucketPushPayload("has/slashes"), "bitbucket push branch name with slashes, targetRevision not prefixed"}, 702 {true, "has/slashes", bitbucketRefChangedPayload("has/slashes"), "bitbucket ref changed branch name with slashes, targetRevision not prefixed"}, 703 {true, "has/slashes", gogsPushPayload("has/slashes"), "gogs push branch name with slashes, targetRevision not prefixed"}, 704 705 {true, "refs/heads/has/slashes", githubPushPayload("has/slashes"), "github push branch name with slashes, targetRevision branch prefixed"}, 706 {true, "refs/heads/has/slashes", gitlabPushPayload("has/slashes"), "gitlab push branch name with slashes, targetRevision branch prefixed"}, 707 {true, "refs/heads/has/slashes", bitbucketPushPayload("has/slashes"), "bitbucket push branch name with slashes, targetRevision branch prefixed"}, 708 {true, "refs/heads/has/slashes", bitbucketRefChangedPayload("has/slashes"), "bitbucket ref changed branch name with slashes, targetRevision branch prefixed"}, 709 {true, "refs/heads/has/slashes", gogsPushPayload("has/slashes"), "gogs push branch name with slashes, targetRevision branch prefixed"}, 710 711 // Not testing for refs/tags/has/slashes, because apparently tags can't have slashes: https://stackoverflow.com/a/32850142/684776 712 713 {true, "no-slashes", githubPushPayload("no-slashes"), "github push branch or tag name without slashes, targetRevision not prefixed"}, 714 {true, "no-slashes", gitlabTagPayload("no-slashes"), "gitlab tag branch or tag name without slashes, targetRevision not prefixed"}, 715 {true, "no-slashes", gitlabPushPayload("no-slashes"), "gitlab push branch or tag name without slashes, targetRevision not prefixed"}, 716 {true, "no-slashes", bitbucketPushPayload("no-slashes"), "bitbucket push branch or tag name without slashes, targetRevision not prefixed"}, 717 {true, "no-slashes", bitbucketRefChangedPayload("no-slashes"), "bitbucket ref changed branch or tag name without slashes, targetRevision not prefixed"}, 718 {true, "no-slashes", gogsPushPayload("no-slashes"), "gogs push branch or tag name without slashes, targetRevision not prefixed"}, 719 720 {true, "refs/heads/no-slashes", githubPushPayload("no-slashes"), "github push branch or tag name without slashes, targetRevision branch prefixed"}, 721 {true, "refs/heads/no-slashes", gitlabTagPayload("no-slashes"), "gitlab tag branch or tag name without slashes, targetRevision branch prefixed"}, 722 {true, "refs/heads/no-slashes", gitlabPushPayload("no-slashes"), "gitlab push branch or tag name without slashes, targetRevision branch prefixed"}, 723 {true, "refs/heads/no-slashes", bitbucketPushPayload("no-slashes"), "bitbucket push branch or tag name without slashes, targetRevision branch prefixed"}, 724 {true, "refs/heads/no-slashes", bitbucketRefChangedPayload("no-slashes"), "bitbucket ref changed branch or tag name without slashes, targetRevision branch prefixed"}, 725 {true, "refs/heads/no-slashes", gogsPushPayload("no-slashes"), "gogs push branch or tag name without slashes, targetRevision branch prefixed"}, 726 727 {true, "refs/tags/no-slashes", githubPushPayload("no-slashes"), "github push branch or tag name without slashes, targetRevision tag prefixed"}, 728 {true, "refs/tags/no-slashes", gitlabTagPayload("no-slashes"), "gitlab tag branch or tag name without slashes, targetRevision tag prefixed"}, 729 {true, "refs/tags/no-slashes", gitlabPushPayload("no-slashes"), "gitlab push branch or tag name without slashes, targetRevision tag prefixed"}, 730 {true, "refs/tags/no-slashes", bitbucketPushPayload("no-slashes"), "bitbucket push branch or tag name without slashes, targetRevision tag prefixed"}, 731 {true, "refs/tags/no-slashes", bitbucketRefChangedPayload("no-slashes"), "bitbucket ref changed branch or tag name without slashes, targetRevision tag prefixed"}, 732 {true, "refs/tags/no-slashes", gogsPushPayload("no-slashes"), "gogs push branch or tag name without slashes, targetRevision tag prefixed"}, 733 734 // Tests fix for https://github.com/argoproj/argo-cd/security/advisories/GHSA-wp4p-9pxh-cgx2 735 {true, "test", gogsclient.PushPayload{Ref: "test", Repo: nil}, "gogs push branch with nil repo in payload"}, 736 737 // Testing fix for https://github.com/argoproj/argo-cd/security/advisories/GHSA-gpx4-37g2-c8pv 738 {false, "test", azuredevops.GitPushEvent{Resource: azuredevops.Resource{RefUpdates: []azuredevops.RefUpdate{}}}, "Azure DevOps malformed push event with no ref updates"}, 739 740 {true, "some-ref", bitbucketserver.RepositoryReferenceChangedPayload{ 741 Changes: []bitbucketserver.RepositoryChange{ 742 {Reference: bitbucketserver.RepositoryReference{ID: "refs/heads/some-ref"}}, 743 }, 744 Repository: bitbucketserver.Repository{Links: map[string]any{"clone": "boom"}}, // The string "boom" here is what previously caused a panic. 745 }, "bitbucket push branch or tag name, malformed link"}, // https://github.com/argoproj/argo-cd/security/advisories/GHSA-f9gq-prrc-hrhc 746 747 {true, "some-ref", bitbucketserver.RepositoryReferenceChangedPayload{ 748 Changes: []bitbucketserver.RepositoryChange{ 749 {Reference: bitbucketserver.RepositoryReference{ID: "refs/heads/some-ref"}}, 750 }, 751 Repository: bitbucketserver.Repository{Links: map[string]any{"clone": []any{map[string]any{"name": "http", "href": []string{}}}}}, // The href as an empty array is what previously caused a panic. 752 }, "bitbucket push branch or tag name, malformed href"}, 753 } 754 for _, testCase := range tests { 755 testCopy := testCase 756 t.Run(testCopy.name, func(t *testing.T) { 757 t.Parallel() 758 h := NewMockHandler(nil, []string{}) 759 _, revisionFromHook, _, _, _ := h.affectedRevisionInfo(testCopy.hookPayload) 760 if got := sourceRevisionHasChanged(sourceWithRevision(testCopy.targetRevision), revisionFromHook, false); got != testCopy.hasChanged { 761 t.Errorf("sourceRevisionHasChanged() = %v, want %v", got, testCopy.hasChanged) 762 } 763 }) 764 } 765 } 766 767 func Test_GetWebURLRegex(t *testing.T) { 768 t.Parallel() 769 770 tests := []struct { 771 shouldMatch bool 772 webURL string 773 repo string 774 name string 775 }{ 776 // Ensure input is regex-escaped. 777 {false, "https://example.com/org/a..d", "https://example.com/org/abcd", "dots in repo names should not be treated as wildcards"}, 778 {false, "https://an.example.com/org/repo", "https://an-example.com/org/repo", "dots in domain names should not be treated as wildcards"}, 779 780 // Standard cases. 781 {true, "https://example.com/org/repo", "https://example.com/org/repo", "exact match should match"}, 782 {false, "https://example.com/org/repo", "https://example.com/org/repo-2", "partial match should not match"}, 783 {true, "https://example.com/org/repo", "https://example.com/org/repo.git", "no .git should match with .git"}, 784 {true, "https://example.com/org/repo", "git@example.com:org/repo", "git without protocol should match"}, 785 {true, "https://example.com/org/repo", "user@example.com:org/repo", "git with non-git username should match"}, 786 {true, "https://example.com/org/repo", "ssh://git@example.com/org/repo", "git with protocol should match"}, 787 {true, "https://example.com/org/repo", "ssh://git@example.com:22/org/repo", "git with port number should match"}, 788 {true, "https://example.com:443/org/repo", "ssh://git@example.com:22/org/repo", "https and ssh w/ different port numbers should match"}, 789 {true, "https://example.com:443/org/repo", "ssh://git@ssh.example.com:443/org/repo", "https and ssh w/ ssh subdomain should match"}, 790 {true, "https://example.com:443/org/repo", "ssh://git@altssh.example.com:443/org/repo", "https and ssh w/ altssh subdomain should match"}, 791 {false, "https://example.com:443/org/repo", "ssh://git@unknown.example.com:443/org/repo", "https and ssh w/ unknown subdomain should not match"}, 792 {true, "https://example.com/org/repo", "ssh://user-name@example.com/org/repo", "valid usernames with hyphens in repo should match"}, 793 {false, "https://example.com/org/repo", "ssh://-user-name@example.com/org/repo", "invalid usernames with hyphens in repo should not match"}, 794 {true, "https://example.com:443/org/repo", "GIT@EXAMPLE.COM:22:ORG/REPO", "matches aren't case-sensitive"}, 795 {true, "https://example.com/org/repo%20", "https://example.com/org/repo%20", "escape codes in path are preserved"}, 796 {true, "https://user@example.com/org/repo", "http://example.com/org/repo", "https+username should match http"}, 797 {true, "https://user@example.com/org/repo", "https://example.com/org/repo", "https+username should match https"}, 798 {true, "http://example.com/org/repo", "https://user@example.com/org/repo", "http should match https+username"}, 799 {true, "https://example.com/org/repo", "https://user@example.com/org/repo", "https should match https+username"}, 800 {true, "https://user@example.com/org/repo", "ssh://example.com/org/repo", "https+username should match ssh"}, 801 802 {false, "", "", "empty URLs should not panic"}, 803 } 804 805 for _, testCase := range tests { 806 testCopy := testCase 807 t.Run(testCopy.name, func(t *testing.T) { 808 t.Parallel() 809 regexp, err := GetWebURLRegex(testCopy.webURL) 810 require.NoError(t, err) 811 if matches := regexp.MatchString(testCopy.repo); matches != testCopy.shouldMatch { 812 t.Errorf("sourceRevisionHasChanged() = %v, want %v", matches, testCopy.shouldMatch) 813 } 814 }) 815 } 816 817 t.Run("bad URL should error", func(t *testing.T) { 818 _, err := GetWebURLRegex("%%") 819 require.Error(t, err) 820 }) 821 } 822 823 func Test_GetAPIURLRegex(t *testing.T) { 824 t.Parallel() 825 826 tests := []struct { 827 shouldMatch bool 828 apiURL string 829 repo string 830 name string 831 }{ 832 // Ensure input is regex-escaped. 833 {false, "https://an.example.com/", "https://an-example.com/", "dots in domain names should not be treated as wildcards"}, 834 835 // Standard cases. 836 {true, "https://example.com/", "https://example.com/", "exact match should match"}, 837 {false, "https://example.com/", "ssh://example.com/", "should not match ssh"}, 838 {true, "https://user@example.com/", "http://example.com/", "https+username should match http"}, 839 {true, "https://user@example.com/", "https://example.com/", "https+username should match https"}, 840 {true, "http://example.com/", "https://user@example.com/", "http should match https+username"}, 841 {true, "https://example.com/", "https://user@example.com/", "https should match https+username"}, 842 } 843 844 for _, testCase := range tests { 845 testCopy := testCase 846 t.Run(testCopy.name, func(t *testing.T) { 847 t.Parallel() 848 regexp, err := GetAPIURLRegex(testCopy.apiURL) 849 require.NoError(t, err) 850 if matches := regexp.MatchString(testCopy.repo); matches != testCopy.shouldMatch { 851 t.Errorf("sourceRevisionHasChanged() = %v, want %v", matches, testCopy.shouldMatch) 852 } 853 }) 854 } 855 856 t.Run("bad URL should error", func(t *testing.T) { 857 _, err := GetAPIURLRegex("%%") 858 require.Error(t, err) 859 }) 860 } 861 862 func TestGitHubCommitEventMaxPayloadSize(t *testing.T) { 863 hook := test.NewGlobal() 864 maxPayloadSize := int64(100) 865 h := NewMockHandlerWithPayloadLimit(nil, []string{}, maxPayloadSize) 866 req := httptest.NewRequest(http.MethodPost, "/api/webhook", http.NoBody) 867 req.Header.Set("X-GitHub-Event", "push") 868 eventJSON, err := os.ReadFile("testdata/github-commit-event.json") 869 require.NoError(t, err) 870 req.Body = io.NopCloser(bytes.NewReader(eventJSON)) 871 w := httptest.NewRecorder() 872 h.Handler(w, req) 873 close(h.queue) 874 h.Wait() 875 assert.Equal(t, http.StatusBadRequest, w.Code) 876 expectedLogResult := "Webhook processing failed: The payload is either too large or corrupted. Please check the payload size (must be under 0 MB) and ensure it is valid JSON" 877 assert.Equal(t, expectedLogResult, hook.LastEntry().Message) 878 hook.Reset() 879 } 880 881 func Test_affectedRevisionInfo_bitbucket_changed_files(t *testing.T) { 882 httpmock.Activate() 883 defer httpmock.DeactivateAndReset() 884 httpmock.RegisterResponder("GET", 885 "https://api.bitbucket.org/2.0/repositories/test-owner/test-repo/diffstat/abcdef..ghijkl", 886 getDiffstatResponderFn()) 887 httpmock.RegisterResponder("GET", 888 "https://api.bitbucket.org/2.0/repositories/test-owner/test-repo", 889 getRepositoryResponderFn()) 890 const payloadTemplateString = ` 891 { 892 "push":{ 893 "changes":[ 894 {"new":{"name":"{{.branch}}", "target": {"hash": "{{.newHash}}"}}, "old": {"name":"{{.branch}}", "target": {"hash": "{{.oldHash}}"}}} 895 ] 896 }, 897 "repository":{ 898 "type": "repository", 899 "full_name": "{{.owner}}/{{.repo}}", 900 "name": "{{.repo}}", 901 "scm": "git", 902 "links": { 903 "self": {"href": "https://api.bitbucket.org/2.0/repositories/{{.owner}}/{{.repo}}"}, 904 "html": {"href": "https://bitbucket.org/{{.owner}}/{{.repo}}"} 905 } 906 } 907 }` 908 tmpl, err := template.New("test").Parse(payloadTemplateString) 909 if err != nil { 910 panic(err) 911 } 912 913 bitbucketPushPayload := func(branchName, owner, repo string) bitbucket.RepoPushPayload { 914 // The payload's "push.changes[0].new.name" member seems to only have the branch name (based on the example payload). 915 // https://support.atlassian.com/bitbucket-cloud/docs/event-payloads/#EventPayloads-Push 916 var pl bitbucket.RepoPushPayload 917 var doc bytes.Buffer 918 err = tmpl.Execute(&doc, map[string]string{ 919 "branch": branchName, 920 "owner": owner, 921 "repo": repo, 922 "oldHash": "abcdef", 923 "newHash": "ghijkl", 924 }) 925 if err != nil { 926 require.NoError(t, err) 927 } 928 _ = json.Unmarshal(doc.Bytes(), &pl) 929 return pl 930 } 931 932 tests := []struct { 933 name string 934 hasChanged bool 935 revision string 936 hookPayload bitbucket.RepoPushPayload 937 expectedTouchHead bool 938 expectedChangedFiles []string 939 expectedChangeInfo changeInfo 940 }{ 941 { 942 "bitbucket branch name containing 'refs/heads/'", 943 false, 944 "release-0.0", 945 bitbucketPushPayload("release-0.0", "test-owner", "test-repo"), 946 false, 947 []string{"guestbook/guestbook-ui-deployment.yaml"}, 948 changeInfo{ 949 shaBefore: "abcdef", 950 shaAfter: "ghijkl", 951 }, 952 }, 953 { 954 "bitbucket branch name containing 'main'", 955 false, 956 "main", 957 bitbucketPushPayload("main", "test-owner", "test-repo"), 958 true, 959 []string{"guestbook/guestbook-ui-deployment.yaml"}, 960 changeInfo{ 961 shaBefore: "abcdef", 962 shaAfter: "ghijkl", 963 }, 964 }, 965 } 966 for _, testCase := range tests { 967 t.Run(testCase.name, func(t *testing.T) { 968 h := NewMockHandlerForBitbucketCallback(nil, []string{}) 969 _, revisionFromHook, change, touchHead, changedFiles := h.affectedRevisionInfo(testCase.hookPayload) 970 require.Equal(t, testCase.revision, revisionFromHook) 971 require.Equal(t, testCase.expectedTouchHead, touchHead) 972 require.Equal(t, testCase.expectedChangedFiles, changedFiles) 973 require.Equal(t, testCase.expectedChangeInfo, change) 974 }) 975 } 976 } 977 978 func TestLookupRepository(t *testing.T) { 979 mockCtx, cancel := context.WithDeadline(t.Context(), time.Now().Add(10*time.Second)) 980 defer cancel() 981 h := NewMockHandlerForBitbucketCallback(nil, []string{}) 982 data := []string{ 983 "https://bitbucket.org/test/argocd-examples-pub.git", 984 "https://bitbucket.org/test/argocd-examples-pub", 985 "https://BITBUCKET.org/test/argocd-examples-pub", 986 "https://BITBUCKET.org/test/argocd-examples-pub.git", 987 "\thttps://bitbucket.org/test/argocd-examples-pub\n", 988 "\thttps://bitbucket.org/test/argocd-examples-pub.git\n", 989 "git@BITBUCKET.org:test/argocd-examples-pub", 990 "git@BITBUCKET.org:test/argocd-examples-pub.git", 991 "git@bitbucket.org:test/argocd-examples-pub", 992 "git@bitbucket.org:test/argocd-examples-pub.git", 993 } 994 for _, url := range data { 995 repository, err := h.lookupRepository(mockCtx, url) 996 require.NoError(t, err) 997 require.NotNil(t, repository) 998 require.Contains(t, strings.ToLower(repository.Repo), strings.Trim(strings.ToLower(url), "\t\n")) 999 require.True(t, repository.Username == "test" || repository.SSHPrivateKey == "test-ssh-key") 1000 } 1001 // when no matching repository is found, then it should return nil error and nil repository 1002 repository, err := h.lookupRepository(t.Context(), "https://bitbucket.org/test/argocd-examples-not-found.git") 1003 require.NoError(t, err) 1004 require.Nil(t, repository) 1005 } 1006 1007 func TestCreateBitbucketClient(t *testing.T) { 1008 tests := []struct { 1009 name string 1010 apiURL string 1011 repository *v1alpha1.Repository 1012 expectedAuth string 1013 expectedErr error 1014 }{ 1015 { 1016 "client creation with username and password", 1017 "https://api.bitbucket.org/2.0/", 1018 &v1alpha1.Repository{ 1019 Repo: "https://bitbucket.org/test", 1020 Username: "test", 1021 Password: "test", 1022 }, 1023 "user:\"test\", password:\"test\"", 1024 nil, 1025 }, 1026 { 1027 "client creation for user x-token-auth and token in password", 1028 "https://api.bitbucket.org/2.0/", 1029 &v1alpha1.Repository{ 1030 Repo: "https://bitbucket.org/test", 1031 Username: "x-token-auth", 1032 Password: "test-token", 1033 }, 1034 "bearerToken:\"test-token\"", 1035 nil, 1036 }, 1037 { 1038 "client creation with oauth bearer token", 1039 "https://api.bitbucket.org/2.0/", 1040 &v1alpha1.Repository{ 1041 Repo: "https://bitbucket.org/test", 1042 BearerToken: "test-token", 1043 }, 1044 "bearerToken:\"test-token\"", 1045 nil, 1046 }, 1047 { 1048 "client creation with no auth", 1049 "https://api.bitbucket.org/2.0/", 1050 &v1alpha1.Repository{ 1051 Repo: "https://bitbucket.org/test", 1052 }, 1053 "bearerToken:\"\"", 1054 nil, 1055 }, 1056 { 1057 "client creation with invalid api URL", 1058 "api.bitbucket.org%%/2.0/", 1059 &v1alpha1.Repository{}, 1060 "", 1061 errors.New("failed to parse bitbucket api base URL 'api.bitbucket.org%%/2.0/'"), 1062 }, 1063 } 1064 for _, tt := range tests { 1065 t.Run(tt.name, func(t *testing.T) { 1066 client, err := newBitbucketClient(t.Context(), tt.repository, tt.apiURL) 1067 if tt.expectedErr == nil { 1068 require.NoError(t, err) 1069 require.NotNil(t, client) 1070 require.Equal(t, tt.apiURL, client.GetApiBaseURL()) 1071 require.Contains(t, fmt.Sprintf("%#v", *client.Auth), tt.expectedAuth) 1072 } else { 1073 require.Error(t, err) 1074 require.Nil(t, client) 1075 require.Equal(t, tt.expectedErr, err) 1076 } 1077 }) 1078 } 1079 } 1080 1081 func TestFetchDiffStatBitbucketClient(t *testing.T) { 1082 httpmock.Activate() 1083 defer httpmock.DeactivateAndReset() 1084 httpmock.RegisterResponder("GET", 1085 "https://api.bitbucket.org/2.0/repositories/test-owner/test-repo/diffstat/abcdef..ghijkl", 1086 getDiffstatResponderFn()) 1087 client := bb.NewOAuthbearerToken("") 1088 tt := []struct { 1089 name string 1090 owner string 1091 repo string 1092 spec string 1093 expectedLen int 1094 expectedFileChanged string 1095 expectedErrString string 1096 }{ 1097 { 1098 name: "valid repo and spec", 1099 owner: "test-owner", 1100 repo: "test-repo", 1101 spec: "abcdef..ghijkl", 1102 expectedLen: 1, 1103 expectedFileChanged: "guestbook/guestbook-ui-deployment.yaml", 1104 }, 1105 { 1106 name: "invalid spec", 1107 owner: "test-owner", 1108 repo: "test-repo", 1109 spec: "abcdef..", 1110 expectedErrString: "error getting the diffstat", 1111 }, 1112 } 1113 1114 for _, test := range tt { 1115 t.Run(test.name, func(t *testing.T) { 1116 changedFiles, err := fetchDiffStatFromBitbucket(t.Context(), client, test.owner, test.repo, test.spec) 1117 if test.expectedErrString == "" { 1118 require.NoError(t, err) 1119 require.NotNil(t, changedFiles) 1120 require.Len(t, changedFiles, test.expectedLen) 1121 require.Equal(t, test.expectedFileChanged, changedFiles[0]) 1122 } else { 1123 require.Error(t, err) 1124 require.Contains(t, err.Error(), test.expectedErrString) 1125 } 1126 }) 1127 } 1128 } 1129 1130 func TestIsHeadTouched(t *testing.T) { 1131 httpmock.Activate() 1132 defer httpmock.DeactivateAndReset() 1133 httpmock.RegisterResponder("GET", 1134 "https://api.bitbucket.org/2.0/repositories/test-owner/test-repo", 1135 getRepositoryResponderFn()) 1136 client := bb.NewOAuthbearerToken("") 1137 tt := []struct { 1138 name string 1139 owner string 1140 repo string 1141 revision string 1142 expectedErrString string 1143 expectedTouchHead bool 1144 }{ 1145 { 1146 name: "valid repo with main branch in revision", 1147 owner: "test-owner", 1148 repo: "test-repo", 1149 revision: "main", 1150 expectedErrString: "", 1151 expectedTouchHead: true, 1152 }, 1153 { 1154 name: "valid repo with main branch in revision", 1155 owner: "test-owner", 1156 repo: "test-repo", 1157 revision: "release-0.0", 1158 expectedErrString: "", 1159 expectedTouchHead: false, 1160 }, 1161 { 1162 name: "valid repo with main branch in revision", 1163 owner: "test-owner", 1164 repo: "unknown-repo", 1165 revision: "master", 1166 expectedErrString: "Get \"https://api.bitbucket.org/2.0/repositories/test-owner/unknown-repo\"", 1167 expectedTouchHead: false, 1168 }, 1169 } 1170 for _, test := range tt { 1171 t.Run(test.name, func(t *testing.T) { 1172 touchedHead, err := isHeadTouched(t.Context(), client, test.owner, test.repo, test.revision) 1173 if test.expectedErrString == "" { 1174 require.NoError(t, err) 1175 require.Equal(t, test.expectedTouchHead, touchedHead) 1176 } else { 1177 require.Error(t, err) 1178 require.False(t, touchedHead) 1179 } 1180 }) 1181 } 1182 } 1183 1184 // getRepositoryResponderFn return a httpmock responder function to mock a get repository api call to bitbucket server 1185 func getRepositoryResponderFn() func(req *http.Request) (*http.Response, error) { 1186 return func(_ *http.Request) (*http.Response, error) { 1187 // sample response: https://api.bitbucket.org/2.0/repositories/anandjoseph/argocd-examples-pub 1188 repository := &bb.Repository{ 1189 Type: "repository", 1190 Full_name: "test-owner/test-repo", 1191 Name: "test-repo", 1192 Is_private: false, 1193 Fork_policy: "allow_forks", 1194 Mainbranch: bb.RepositoryBranch{ 1195 Name: "main", 1196 Type: "branch", 1197 }, 1198 } 1199 resp, err := httpmock.NewJsonResponse(200, repository) 1200 if err != nil { 1201 return httpmock.NewStringResponse(500, ""), nil 1202 } 1203 return resp, nil 1204 } 1205 } 1206 1207 // getDiffstatResponderFn return a httpmock responder function to mock a diffstat api call to bitbucket server 1208 func getDiffstatResponderFn() func(req *http.Request) (*http.Response, error) { 1209 return func(_ *http.Request) (*http.Response, error) { 1210 // sample response : https://api.bitbucket.org/2.0/repositories/anandjoseph/argocd-examples-pub/diffstat/3a53cee247fc820fbae0a9cf463a6f4a18369f90..3d0965f36fcc07e88130b2d5c917a37c2876c484 1211 diffStatRes := &bb.DiffStatRes{ 1212 Page: 1, 1213 Size: 1, 1214 Pagelen: 500, 1215 DiffStats: []*bb.DiffStat{ 1216 { 1217 Type: "diffstat", 1218 Status: "added", 1219 LinedAdded: 20, 1220 LinesRemoved: 0, 1221 New: map[string]any{ 1222 "path": "guestbook/guestbook-ui-deployment.yaml", 1223 "type": "commit_file", 1224 "escaped_path": "guestbook/guestbook-ui-deployment.yaml", 1225 "links": map[string]any{ 1226 "self": map[string]any{ 1227 "href": "https://bitbucket.org/guestbook/guestbook-ui-deployment.yaml", 1228 }, 1229 }, 1230 }, 1231 }, 1232 }, 1233 } 1234 resp, err := httpmock.NewJsonResponse(200, diffStatRes) 1235 if err != nil { 1236 return httpmock.NewStringResponse(500, ""), nil 1237 } 1238 return resp, nil 1239 } 1240 }