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  }