diff --git a/server/plugin/test_utils.go b/server/plugin/test_utils.go index 3761f613e..231851203 100644 --- a/server/plugin/test_utils.go +++ b/server/plugin/test_utils.go @@ -2,8 +2,12 @@ package plugin import ( "context" + "crypto/hmac" + "crypto/sha1" // #nosec G505 + "encoding/hex" "fmt" "testing" + "time" "github.com/golang/mock/gomock" "github.com/google/go-github/v54/github" @@ -15,22 +19,30 @@ import ( ) const ( - MockUserID = "mockUserID" - MockUsername = "mockUsername" - MockAccessToken = "mockAccessToken" - MockChannelID = "mockChannelID" - MockCreatorID = "mockCreatorID" - MockBotID = "mockBotID" - MockPostMessage = "mockPostMessage" - MockOrgRepo = "mockOrg/mockRepo" - MockHead = "mockHead" - MockRepoName = "mockRepoName" - MockEventReference = "refs/heads/main" - MockUserLogin = "mockUser" - MockBranch = "mockBranch" - MockRepo = "mockRepo" - MockIssueAuthor = "issueAuthor" - GithubBaseURL = "https://github.com/" + MockUserID = "mockUserID" + MockUsername = "mockUsername" + MockAccessToken = "mockAccessToken" + MockChannelID = "mockChannelID" + MockCreatorID = "mockCreatorID" + MockWebhookSecret = "mockWebhookSecret" // #nosec G101 + MockBotID = "mockBotID" + MockOrg = "mockOrg" + MockSender = "mockSender" + MockPostMessage = "mockPostMessage" + MockOrgRepo = "mockOrg/mockRepo" + MockHead = "mockHead" + MockPRTitle = "mockPRTitle" + MockProfileUsername = "@username" + MockPostID = "mockPostID" + MockRepoName = "mockRepoName" + MockEventReference = "refs/heads/main" + MockUserLogin = "mockUser" + MockBranch = "mockBranch" + MockRepo = "mockRepo" + MockLabel = "mockLabel" + MockValidLabel = "validLabel" + MockIssueAuthor = "issueAuthor" + GithubBaseURL = "https://github.com/" ) type GitHubUserResponse struct { @@ -91,6 +103,104 @@ func GetMockUserContext(p *Plugin, mockLogger *mocks.MockLogger) (*UserContext, return mockUserContext, nil } +func generateSignature(secret, body []byte) string { + h := hmac.New(sha1.New, secret) + h.Write(body) + return "sha1=" + hex.EncodeToString(h.Sum(nil)) +} + +func GetMockPingEvent() *github.PingEvent { + return &github.PingEvent{ + Zen: github.String("Keep it logically awesome."), + HookID: github.Int64(123456), + Hook: &github.Hook{ + Type: github.String("Repository"), + ID: github.Int64(654321), + Config: map[string]interface{}{ + "url": "https://example.com/webhook", + "content_type": "json", + "secret": "mocksecret", + "insecure_ssl": "0", + }, + Active: github.Bool(true), + }, + Repo: &github.Repository{ + Name: github.String(MockRepoName), + FullName: github.String(MockOrgRepo), + Private: github.Bool(false), + HTMLURL: github.String(fmt.Sprintf("%s/%s", GithubBaseURL, MockOrgRepo)), + }, + Org: &github.Organization{ + Login: github.String("mockorg"), + ID: github.Int64(12345), + URL: github.String(fmt.Sprintf("%s/mockorg", GithubBaseURL)), + }, + Sender: &github.User{ + Login: github.String(MockUserLogin), + ID: github.Int64(98765), + URL: github.String(fmt.Sprintf("%s/users/%s", GithubBaseURL, MockUserLogin)), + }, + Installation: &github.Installation{ + ID: github.Int64(246810), + NodeID: github.String("MDQ6VXNlcjE="), + }, + } +} + +func GetMockPRDescriptionEvent(repo, org, sender, prUser, action, label string) *github.PullRequestEvent { + return &github.PullRequestEvent{ + Action: github.String(action), + PullRequest: &github.PullRequest{ + Title: github.String(MockPRTitle), + Body: github.String("Mock PR description with label: " + label), + State: github.String("open"), + User: &github.User{Login: github.String(prUser)}, + Head: &github.PullRequestBranch{Ref: github.String(MockBranch)}, + Base: &github.PullRequestBranch{Ref: github.String("main")}, + HTMLURL: github.String(GithubBaseURL + org + "/" + repo + "/pull/1"), + Number: github.Int(1), + }, + Repo: &github.Repository{ + Name: github.String(repo), + Owner: &github.User{Login: github.String(org)}, + FullName: github.String(org + "/" + repo), + }, + Sender: &github.User{ + Login: github.String(sender), + }, + } +} + +func GetMockIssueEvent(repo, org, sender, action, label string) *github.IssuesEvent { + event := &github.IssuesEvent{ + Repo: &github.Repository{ + Name: github.String(repo), + Owner: &github.User{Login: github.String(org)}, + FullName: github.String(fmt.Sprintf("%s/%s", repo, org)), + }, + Sender: &github.User{Login: github.String(sender)}, + Issue: &github.Issue{ + Number: github.Int(123), + Labels: []*github.Label{ + {Name: github.String(label)}, + }, + }, + Action: github.String(action), + } + + if action == actionLabeled || action == "unlabeled" { + event.Label = &github.Label{Name: github.String(label)} + } + + return event +} + +func GetMockIssueEventWithTimeDiff(repo, org, sender, action, label string, timeDiff time.Duration) *github.IssuesEvent { + event := GetMockIssueEvent(repo, org, sender, action, label) + event.Issue.CreatedAt = &github.Timestamp{Time: time.Now().Add(timeDiff)} + return event +} + func GetMockPushEvent() *github.PushEvent { return &github.PushEvent{ PushID: github.Int64(1), @@ -253,22 +363,25 @@ func GetMockDeleteEventWithInvalidType() *github.DeleteEvent { } } -func GetMockPullRequestReviewEvent(action, state string) *github.PullRequestReviewEvent { +func GetMockPullRequestReviewEvent(action, state, repo string, isPrivate bool, reviewer, author string) *github.PullRequestReviewEvent { return &github.PullRequestReviewEvent{ Action: github.String(action), Repo: &github.Repository{ - Name: github.String(MockRepoName), + Name: github.String(repo), FullName: github.String(MockOrgRepo), - Private: github.Bool(false), + Private: github.Bool(isPrivate), HTMLURL: github.String(fmt.Sprintf("%s%s", GithubBaseURL, MockOrgRepo)), }, + Sender: &github.User{Login: github.String(reviewer)}, Review: &github.PullRequestReview{ + User: &github.User{ + Login: github.String(reviewer), + }, State: github.String(state), }, - Sender: &github.User{ - Login: github.String(MockUserLogin), + PullRequest: &github.PullRequest{ + User: &github.User{Login: github.String(author)}, }, - PullRequest: &github.PullRequest{}, } } @@ -346,9 +459,10 @@ func GetMockIssueCommentEventWithAssignees(eventType, action, body, sender strin } } -func GetMockPullRequestEvent(action, repoName string, isPrivate bool, sender, user, assignee string) *github.PullRequestEvent { +func GetMockPullRequestEvent(action, repoName, eventLabel string, isPrivate bool, sender, user, assignee string) *github.PullRequestEvent { return &github.PullRequestEvent{ Action: github.String(action), + Label: &github.Label{Name: github.String(eventLabel)}, Repo: &github.Repository{ Name: github.String(repoName), FullName: github.String(fmt.Sprintf("mockOrg/%s", repoName)), @@ -359,6 +473,8 @@ func GetMockPullRequestEvent(action, repoName string, isPrivate bool, sender, us HTMLURL: github.String(fmt.Sprintf("%s%s/%s/pull/123", GithubBaseURL, MockOrgRepo, repoName)), Assignee: &github.User{Login: github.String(assignee)}, RequestedReviewers: []*github.User{{Login: github.String(user)}}, + Labels: []*github.Label{{Name: github.String("validLabel")}}, + Draft: github.Bool(true), }, Sender: &github.User{ Login: github.String(sender), @@ -366,3 +482,70 @@ func GetMockPullRequestEvent(action, repoName string, isPrivate bool, sender, us RequestedReviewer: &github.User{Login: github.String(user)}, } } + +func GetMockIssuesEvent(action, repoName string, isPrivate bool, author, sender, assignee string) *github.IssuesEvent { + return &github.IssuesEvent{ + Action: &action, + Repo: &github.Repository{FullName: &repoName, Private: &isPrivate}, + Issue: &github.Issue{User: &github.User{Login: &author}}, + Sender: &github.User{Login: &sender}, + Assignee: func() *github.User { + if assignee == "" { + return nil + } + return &github.User{Login: &assignee} + }(), + } +} + +func GetMockStarEvent(repo, org string, isPrivate bool, sender string) *github.StarEvent { + return &github.StarEvent{ + Repo: &github.Repository{ + Name: github.String(repo), + Private: github.Bool(isPrivate), + FullName: github.String(fmt.Sprintf("%s/%s", repo, org)), + }, + Sender: &github.User{Login: github.String(sender)}, + } +} + +func GetMockReleaseEvent(repo, org, action, sender string) *github.ReleaseEvent { + return &github.ReleaseEvent{ + Action: &action, + Repo: &github.Repository{ + Name: github.String(repo), + Owner: &github.User{Login: github.String(org)}, + FullName: github.String(fmt.Sprintf("%s/%s", repo, org)), + }, + Sender: &github.User{Login: github.String(sender)}, + } +} + +func GetMockDiscussionEvent(repo, org, sender string) *github.DiscussionEvent { + return &github.DiscussionEvent{ + Repo: &github.Repository{ + Name: github.String(repo), + Owner: &github.User{Login: github.String(org)}, + FullName: github.String(fmt.Sprintf("%s/%s", repo, org)), + }, + Sender: &github.User{Login: github.String(sender)}, + Discussion: &github.Discussion{ + Number: github.Int(123), + }, + } +} + +func GetMockDiscussionCommentEvent(repo, org, action, sender string) *github.DiscussionCommentEvent { + return &github.DiscussionCommentEvent{ + Action: &action, + Repo: &github.Repository{ + Name: github.String(repo), + Owner: &github.User{Login: github.String(org)}, + FullName: github.String(fmt.Sprintf("%s/%s", repo, org)), + }, + Sender: &github.User{Login: github.String(sender)}, + Comment: &github.CommentDiscussion{ + ID: github.Int64(456), + }, + } +} diff --git a/server/plugin/utils.go b/server/plugin/utils.go index 4094c9801..f1e7f61d6 100644 --- a/server/plugin/utils.go +++ b/server/plugin/utils.go @@ -389,3 +389,18 @@ func lastN(s string, n int) string { return string(out) } + +func GetMockSubscriptionWithLabel(repo string, feature string) *Subscriptions { + return &Subscriptions{ + Repositories: map[string][]*Subscription{ + repo: { + { + ChannelID: MockChannelID, + CreatorID: MockCreatorID, + Features: Features(feature), + Repository: MockRepo, + }, + }, + }, + } +} diff --git a/server/plugin/webhook_test.go b/server/plugin/webhook_test.go index 1334f97d3..d5eea10fd 100644 --- a/server/plugin/webhook_test.go +++ b/server/plugin/webhook_test.go @@ -1,7 +1,14 @@ package plugin import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "sync" "testing" + "time" "github.com/golang/mock/gomock" "github.com/google/go-github/v54/github" @@ -11,25 +18,1335 @@ import ( "github.com/stretchr/testify/mock" ) +func TestVerifyWebhookSignature(t *testing.T) { + tests := []struct { + name string + secret []byte + signature string + body []byte + assertions func(t *testing.T, valid bool, err error) + }{ + { + name: "Valid signature", + secret: []byte("test-secret"), + signature: func() string { + secret := []byte("test-secret") + body := []byte("test-body") + return generateSignature(secret, body) + }(), + body: []byte("test-body"), + assertions: func(t *testing.T, valid bool, err error) { + assert.NoError(t, err) + assert.True(t, valid) + }, + }, + { + name: "Invalid signature prefix", + secret: []byte("test-secret"), + signature: "invalid-prefix=1234567890abcdef", + body: []byte("test-body"), + assertions: func(t *testing.T, valid bool, err error) { + assert.NoError(t, err) + assert.False(t, valid) + }, + }, + { + name: "Invalid signature length", + secret: []byte("test-secret"), + signature: "sha1=short", + body: []byte("test-body"), + assertions: func(t *testing.T, valid bool, err error) { + assert.NoError(t, err) + assert.False(t, valid) + }, + }, + { + name: "Hex decode error", + secret: []byte("test-secret"), + signature: "sha1=gggggggggggggggggggggggggggggggggggggggg", + body: []byte("test-body"), + assertions: func(t *testing.T, valid bool, err error) { + assert.Error(t, err) + assert.False(t, valid) + }, + }, + { + name: "HMAC mismatch", + secret: []byte("test-secret"), + signature: "sha1=38cb0302e94c235fb349ac026084db66bc64a979", + body: []byte("different-body"), + assertions: func(t *testing.T, valid bool, err error) { + assert.NoError(t, err) + assert.False(t, valid) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + valid, err := verifyWebhookSignature(tt.secret, tt.signature, tt.body) + + tt.assertions(t, valid, err) + }) + } +} + +func TestGetEventWithRenderConfig(t *testing.T) { + tests := []struct { + name string + event interface{} + sub *Subscription + assertions func(t *testing.T, result *EventWithRenderConfig) + }{ + { + name: "No Subscription", + event: "test-event", + sub: nil, + assertions: func(t *testing.T, result *EventWithRenderConfig) { + assert.Equal(t, "test-event", result.Event) + assert.Empty(t, result.Config.Style) + }, + }, + { + name: "Subscription with RenderStyle", + event: "test-event", + sub: &Subscription{ + ChannelID: "channel-1", + CreatorID: "creator-1", + Repository: "repo-1", + }, + assertions: func(t *testing.T, result *EventWithRenderConfig) { + assert.Equal(t, "test-event", result.Event) + assert.Empty(t, result.Config.Style) + }, + }, + { + name: "Subscription with Custom RenderStyle", + event: "test-event", + sub: &Subscription{ + ChannelID: "channel-1", + CreatorID: "creator-1", + Flags: SubscriptionFlags{RenderStyle: "custom-style"}, + Repository: "repo-1", + }, + assertions: func(t *testing.T, result *EventWithRenderConfig) { + assert.Equal(t, "test-event", result.Event) + assert.Equal(t, "custom-style", result.Config.Style) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GetEventWithRenderConfig(tt.event, tt.sub) + + tt.assertions(t, result) + }) + } +} + +func TestNewWebhookBroker(t *testing.T) { + called := false + mockSendGitHubPingEvent := func(event *github.PingEvent) { + called = true + } + + broker := NewWebhookBroker(mockSendGitHubPingEvent) + + mockSendGitHubPingEvent(nil) + + assert.NotNil(t, broker) + assert.True(t, called, "sendGitHubPingEvent should have been called") +} + +func TestSubscribePings(t *testing.T) { + broker := &WebhookBroker{} + + ch := broker.SubscribePings() + assert.NotNil(t, ch, "Channel should not be nil") + assert.Len(t, broker.pingSubs, 1, "pingSubs should contain one channel") + + testCh := make(chan *github.PingEvent, 1) + go func() { + event := &github.PingEvent{} + testCh <- event + }() + + receivedEvent := <-testCh + assert.NotNil(t, receivedEvent, "Received event should not be nil") +} + +func TestUnsubscribePings(t *testing.T) { + broker := &WebhookBroker{} + ch := broker.SubscribePings() + assert.NotNil(t, ch, "Channel should not be nil") + assert.Len(t, broker.pingSubs, 1, "pingSubs should contain one channel") + + broker.UnsubscribePings(ch) + + broker.UnsubscribePings(ch) + assert.Len(t, broker.pingSubs, 0, "pingSubs should be empty after unsubscribe") + assert.Len(t, broker.pingSubs, 0, "pingSubs should still be empty after second unsubscribe") +} + +func TestPublishPing(t *testing.T) { + broker := &WebhookBroker{pingSubs: []chan *github.PingEvent{}} + event := &github.PingEvent{} + mockSendGitHubPingEvent := func(event *github.PingEvent) {} + broker.sendGitHubPingEvent = mockSendGitHubPingEvent + ch := broker.SubscribePings() + + go func() { + broker.publishPing(event, false) + }() + + var wg sync.WaitGroup + wg.Add(1) + + go func() { + defer wg.Done() + receivedEvent := <-ch + assert.NotNil(t, receivedEvent, "Received event should not be nil") + assert.Equal(t, event, receivedEvent, "Received event should match the published event") + }() + + wg.Wait() + + broker.closed = true + broker.publishPing(event, false) +} + +func TestClose(t *testing.T) { + broker := &WebhookBroker{pingSubs: []chan *github.PingEvent{}} + ch := make(chan *github.PingEvent, 1) + broker.pingSubs = append(broker.pingSubs, ch) + + broker.Close() + + assert.True(t, broker.closed, "Broker should be marked as closed") + select { + case _, open := <-ch: + assert.False(t, open, "Channel should be closed") + default: + t.Error("Channel should be closed") + } +} + +func TestHandleWebhookBadRequestBody(t *testing.T) { + mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) + p := getPluginTest(mockAPI, mockKvStore) + + tests := []struct { + name string + signature func([]byte) string + body []byte + githubEventType string + setup func() + assertions func(t *testing.T, resp *httptest.ResponseRecorder) + }{ + { + name: "failed signature verification (invalid signature)", + body: []byte("valid body"), + signature: func(body []byte) string { return "" }, + githubEventType: "", + setup: func() { + p.setConfiguration(&Configuration{ + WebhookSecret: MockWebhookSecret, + }) + }, + assertions: func(t *testing.T, resp *httptest.ResponseRecorder) { + assert.Equal(t, http.StatusUnauthorized, resp.Code) + }, + }, + { + name: "Request body is not webhook content type", + body: []byte("valid body"), + signature: func(body []byte) string { + return generateSignature([]byte(MockWebhookSecret), body) + }, + githubEventType: "", + setup: func() { + p.setConfiguration(&Configuration{ + WebhookSecret: MockWebhookSecret, + }) + mockAPI.On("LogDebug", "GitHub webhook content type should be set to \"application/json\"", "error", "unknown X-Github-Event in message: ").Times(1) + }, + assertions: func(t *testing.T, resp *httptest.ResponseRecorder) { + assert.Equal(t, http.StatusBadRequest, resp.Code) + }, + }, + { + name: "Successful handle ping event", + body: func() []byte { + event := GetMockPingEvent() + body, err := json.Marshal(event) + assert.NoError(t, err) + return body + }(), + signature: func(body []byte) string { + return generateSignature([]byte(MockWebhookSecret), body) + }, + githubEventType: "ping", + setup: func() { + p.webhookBroker = NewWebhookBroker(p.sendGitHubPingEvent) + p.setConfiguration(&Configuration{ + WebhookSecret: MockWebhookSecret, + EnableWebhookEventLogging: true, + }) + mockAPI.On("LogDebug", "Webhook Event Log", "event", mock.AnythingOfType("string")).Times(1) + mockAPI.On("PublishPluginClusterEvent", mock.AnythingOfType("model.PluginClusterEvent"), mock.AnythingOfType("model.PluginClusterEventSendOptions")).Return(nil).Times(1) + }, + assertions: func(t *testing.T, resp *httptest.ResponseRecorder) { + assert.Equal(t, http.StatusOK, resp.Code) + }, + }, + { + name: "Successful handle pull request event", + body: func() []byte { + event := GetMockPullRequestEvent(actionOpened, MockRepo, "", false, MockSender, MockUserLogin, "") + body, err := json.Marshal(event) + assert.NoError(t, err) + return body + }(), + signature: func(body []byte) string { + return generateSignature([]byte(MockWebhookSecret), body) + }, + githubEventType: "pull_request", + setup: func() { + p.webhookBroker = NewWebhookBroker(p.sendGitHubPingEvent) + p.setConfiguration(&Configuration{ + WebhookSecret: MockWebhookSecret, + EnableWebhookEventLogging: true, + }) + mockAPI.On("LogDebug", "Webhook Event Log", "event", mock.AnythingOfType("string")).Times(1) + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).Return(nil).Times(1) + mockAPI.On("LogDebug", "Unhandled event action", "action", "opened").Times(1) + }, + assertions: func(t *testing.T, resp *httptest.ResponseRecorder) { + assert.Equal(t, http.StatusOK, resp.Code) + }, + }, + { + name: "Successfully handle issue event", + body: func() []byte { + event := GetMockIssueEvent("", "", "", "", "") + body, err := json.Marshal(event) + assert.NoError(t, err) + return body + }(), + signature: func(body []byte) string { + return generateSignature([]byte(MockWebhookSecret), body) + }, + githubEventType: "issues", + setup: func() { + p.webhookBroker = NewWebhookBroker(p.sendGitHubPingEvent) + p.setConfiguration(&Configuration{ + WebhookSecret: MockWebhookSecret, + EnableWebhookEventLogging: true, + }) + mockAPI.On("LogDebug", "Webhook Event Log", "event", mock.AnythingOfType("string")).Times(1) + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).Return(nil).Times(1) + }, + assertions: func(t *testing.T, resp *httptest.ResponseRecorder) { + assert.Equal(t, http.StatusOK, resp.Code) + }, + }, + { + name: "Successfully handle issue comment event", + body: func() []byte { + event := GetMockIssueCommentEvent("", "", "") + body, err := json.Marshal(event) + assert.NoError(t, err) + return body + }(), + signature: func(body []byte) string { + return generateSignature([]byte(MockWebhookSecret), body) + }, + githubEventType: "issue_comment", + setup: func() { + p.webhookBroker = NewWebhookBroker(p.sendGitHubPingEvent) + p.setConfiguration(&Configuration{ + WebhookSecret: MockWebhookSecret, + EnableWebhookEventLogging: true, + }) + mockAPI.On("LogDebug", "Webhook Event Log", "event", mock.AnythingOfType("string")).Times(1) + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).Return(nil).Times(1) + mockKvStore.EXPECT().Get("issueAuthor_githubusername", gomock.Any()).Return(nil).Times(1) + }, + assertions: func(t *testing.T, resp *httptest.ResponseRecorder) { + assert.Equal(t, http.StatusOK, resp.Code) + }, + }, + { + name: "Successfully handle pull request review event", + body: func() []byte { + event := GetMockPullRequestReviewEvent("", "", "", true, "", "") + body, err := json.Marshal(event) + assert.NoError(t, err) + return body + }(), + signature: func(body []byte) string { + return generateSignature([]byte(MockWebhookSecret), body) + }, + githubEventType: "pull_request_review", + setup: func() { + p.webhookBroker = NewWebhookBroker(p.sendGitHubPingEvent) + p.setConfiguration(&Configuration{ + WebhookSecret: MockWebhookSecret, + EnableWebhookEventLogging: true, + }) + mockAPI.On("LogDebug", "Webhook Event Log", "event", mock.AnythingOfType("string")).Times(1) + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).Return(nil).Times(1) + }, + assertions: func(t *testing.T, resp *httptest.ResponseRecorder) { + assert.Equal(t, http.StatusOK, resp.Code) + }, + }, + { + name: "Successfully handle pull request review comment event", + body: func() []byte { + event := GetMockPullRequestReviewCommentEvent() + body, err := json.Marshal(event) + assert.NoError(t, err) + return body + }(), + signature: func(body []byte) string { + return generateSignature([]byte(MockWebhookSecret), body) + }, + githubEventType: "pull_request_review_comment", + setup: func() { + p.webhookBroker = NewWebhookBroker(p.sendGitHubPingEvent) + p.setConfiguration(&Configuration{ + WebhookSecret: MockWebhookSecret, + EnableWebhookEventLogging: true, + }) + mockAPI.On("LogDebug", "Webhook Event Log", "event", mock.AnythingOfType("string")).Times(1) + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).Return(nil).Times(1) + }, + assertions: func(t *testing.T, resp *httptest.ResponseRecorder) { + assert.Equal(t, http.StatusOK, resp.Code) + }, + }, + { + name: "Successfully handle push event", + body: func() []byte { + event := GetMockPushEvent() + body, err := json.Marshal(event) + assert.NoError(t, err) + return body + }(), + signature: func(body []byte) string { + return generateSignature([]byte(MockWebhookSecret), body) + }, + githubEventType: "push", + setup: func() { + p.webhookBroker = NewWebhookBroker(p.sendGitHubPingEvent) + p.setConfiguration(&Configuration{ + WebhookSecret: MockWebhookSecret, + EnableWebhookEventLogging: true, + }) + mockAPI.On("LogDebug", "Webhook Event Log", "event", mock.AnythingOfType("string")).Times(1) + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).Return(nil).Times(1) + }, + assertions: func(t *testing.T, resp *httptest.ResponseRecorder) { + assert.Equal(t, http.StatusOK, resp.Code) + }, + }, + { + name: "Successfully handle create event", + body: func() []byte { + event := GetMockCreateEvent() + body, err := json.Marshal(event) + assert.NoError(t, err) + return body + }(), + signature: func(body []byte) string { + return generateSignature([]byte(MockWebhookSecret), body) + }, + githubEventType: "create", + setup: func() { + p.webhookBroker = NewWebhookBroker(p.sendGitHubPingEvent) + p.setConfiguration(&Configuration{ + WebhookSecret: MockWebhookSecret, + EnableWebhookEventLogging: true, + }) + mockAPI.On("LogDebug", "Webhook Event Log", "event", mock.AnythingOfType("string")).Times(1) + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).Return(nil).Times(1) + }, + assertions: func(t *testing.T, resp *httptest.ResponseRecorder) { + assert.Equal(t, http.StatusOK, resp.Code) + }, + }, + { + name: "Successfully handle delete event", + body: func() []byte { + event := GetMockDeleteEvent() + body, err := json.Marshal(event) + assert.NoError(t, err) + return body + }(), + signature: func(body []byte) string { + return generateSignature([]byte(MockWebhookSecret), body) + }, + githubEventType: "delete", + setup: func() { + p.webhookBroker = NewWebhookBroker(p.sendGitHubPingEvent) + p.setConfiguration(&Configuration{ + WebhookSecret: MockWebhookSecret, + EnableWebhookEventLogging: true, + }) + mockAPI.On("LogDebug", "Webhook Event Log", "event", mock.AnythingOfType("string")).Times(1) + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).Return(nil).Times(1) + }, + assertions: func(t *testing.T, resp *httptest.ResponseRecorder) { + assert.Equal(t, http.StatusOK, resp.Code) + }, + }, + { + name: "Successfully handle start event", + body: func() []byte { + event := GetMockStarEvent("", "", true, "") + body, err := json.Marshal(event) + assert.NoError(t, err) + return body + }(), + signature: func(body []byte) string { + return generateSignature([]byte(MockWebhookSecret), body) + }, + githubEventType: "star", + setup: func() { + p.webhookBroker = NewWebhookBroker(p.sendGitHubPingEvent) + p.setConfiguration(&Configuration{ + WebhookSecret: MockWebhookSecret, + EnableWebhookEventLogging: true, + }) + mockAPI.On("LogDebug", "Webhook Event Log", "event", mock.AnythingOfType("string")).Times(1) + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).Return(nil).Times(1) + }, + assertions: func(t *testing.T, resp *httptest.ResponseRecorder) { + assert.Equal(t, http.StatusOK, resp.Code) + }, + }, + { + name: "Successfully handle release event", + body: func() []byte { + event := GetMockReleaseEvent("", "", "", "") + body, err := json.Marshal(event) + assert.NoError(t, err) + return body + }(), + signature: func(body []byte) string { + return generateSignature([]byte(MockWebhookSecret), body) + }, + githubEventType: "release", + setup: func() { + p.webhookBroker = NewWebhookBroker(p.sendGitHubPingEvent) + p.setConfiguration(&Configuration{ + WebhookSecret: MockWebhookSecret, + EnableWebhookEventLogging: true, + }) + mockAPI.On("LogDebug", "Webhook Event Log", "event", mock.AnythingOfType("string")).Times(1) + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).Return(nil).Times(1) + }, + assertions: func(t *testing.T, resp *httptest.ResponseRecorder) { + assert.Equal(t, http.StatusOK, resp.Code) + }, + }, + { + name: "Successfully handle discussion event", + body: func() []byte { + event := GetMockDiscussionEvent("", "", "") + body, err := json.Marshal(event) + assert.NoError(t, err) + return body + }(), + signature: func(body []byte) string { + return generateSignature([]byte(MockWebhookSecret), body) + }, + githubEventType: "discussion", + setup: func() { + p.webhookBroker = NewWebhookBroker(p.sendGitHubPingEvent) + p.setConfiguration(&Configuration{ + WebhookSecret: MockWebhookSecret, + EnableWebhookEventLogging: true, + }) + mockAPI.On("LogDebug", "Webhook Event Log", "event", mock.AnythingOfType("string")).Times(1) + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).Return(nil).Times(1) + }, + assertions: func(t *testing.T, resp *httptest.ResponseRecorder) { + assert.Equal(t, http.StatusOK, resp.Code) + }, + }, + { + name: "Successfully handle discussion comment event", + body: func() []byte { + event := GetMockDiscussionCommentEvent("", "", "", "") + body, err := json.Marshal(event) + assert.NoError(t, err) + return body + }(), + signature: func(body []byte) string { + return generateSignature([]byte(MockWebhookSecret), body) + }, + githubEventType: "discussion_comment", + setup: func() { + p.webhookBroker = NewWebhookBroker(p.sendGitHubPingEvent) + p.setConfiguration(&Configuration{ + WebhookSecret: MockWebhookSecret, + EnableWebhookEventLogging: true, + }) + mockAPI.On("LogDebug", "Webhook Event Log", "event", mock.AnythingOfType("string")).Times(1) + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).Return(nil).Times(1) + }, + assertions: func(t *testing.T, resp *httptest.ResponseRecorder) { + assert.Equal(t, http.StatusOK, resp.Code) + }, + }, + { + name: "Successfully handle discussion comment event", + body: func() []byte { + event := GetMockDiscussionCommentEvent("", "", "", "") + body, err := json.Marshal(event) + assert.NoError(t, err) + return body + }(), + signature: func(body []byte) string { + return generateSignature([]byte(MockWebhookSecret), body) + }, + githubEventType: "discussion_comment", + setup: func() { + p.webhookBroker = NewWebhookBroker(p.sendGitHubPingEvent) + p.setConfiguration(&Configuration{ + WebhookSecret: MockWebhookSecret, + EnableWebhookEventLogging: true, + }) + mockAPI.On("LogDebug", "Webhook Event Log", "event", mock.AnythingOfType("string")).Times(1) + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).Return(nil).Times(1) + }, + assertions: func(t *testing.T, resp *httptest.ResponseRecorder) { + assert.Equal(t, http.StatusOK, resp.Code) + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.setup() + + req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(tc.body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Hub-Signature", tc.signature(tc.body)) + req.Header.Set("X-GitHub-Event", tc.githubEventType) + resp := httptest.NewRecorder() + + p.handleWebhook(resp, req) + + tc.assertions(t, resp) + }) + } +} + +func TestPostPullRequestEvent(t *testing.T) { + mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) + p := getPluginTest(mockAPI, mockKvStore) + + tests := []struct { + name string + event *github.PullRequestEvent + setup func() + }{ + { + name: "No subscription for channel", + event: GetMockPullRequestEvent(actionCreated, MockRepo, MockValidLabel, false, MockSender, MockUserID, MockUsername), + setup: func() { + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).Return(nil).Times(1) + }, + }, + { + name: "Unsupported action", + event: GetMockPullRequestEvent(actionCreated, MockRepo, MockValidLabel, false, MockSender, MockUserID, MockUsername), + setup: func() { + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(**Subscriptions); ok { + *v = GetMockSubscriptionWithLabel("mockorg/mockrepo", "issues,label:\"validLabel\"") + } + return nil + }).Times(1) + }, + }, + { + name: "Valid subscription does not exist", + event: GetMockPullRequestEvent(actionOpened, MockRepo, MockValidLabel, false, MockSender, MockUserID, MockUsername), + setup: func() { + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(**Subscriptions); ok { + *v = GetMockSubscriptionWithLabel("mockorg/mockrepo", "issues,label:\"validLabel\"") + } + return nil + }).Times(1) + }, + }, + { + name: "PullsMerged subscription exist but PR action is not closed", + event: GetMockPullRequestEvent(actionOpened, MockRepo, MockValidLabel, false, MockSender, MockUserID, MockUsername), + setup: func() { + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(**Subscriptions); ok { + *v = GetMockSubscriptionWithLabel("mockorg/mockrepo", "pulls_merged,label:\"validLabel\"") + } + return nil + }).Times(1) + }, + }, + { + name: "PullsCreated subscription exist but PR action is not opened", + event: GetMockPullRequestEvent(actionClosed, MockRepo, MockValidLabel, false, MockSender, MockUserID, MockUsername), + setup: func() { + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(**Subscriptions); ok { + *v = GetMockSubscriptionWithLabel("mockorg/mockrepo", "pulls_created,label:\"validLabel\"") + } + return nil + }).Times(1) + }, + }, + { + name: "no valid label exists", + event: GetMockPullRequestEvent(actionOpened, MockRepo, MockValidLabel, false, MockSender, MockUserID, MockUsername), + setup: func() { + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(**Subscriptions); ok { + *v = GetMockSubscriptionWithLabel("mockorg/mockrepo", "pulls_created,label:\"invalidLabel\"") + } + return nil + }).Times(1) + }, + }, + { + name: "Error creating post for action labeled", + event: GetMockPullRequestEvent(actionLabeled, MockRepo, MockValidLabel, false, MockSender, MockUserID, MockUsername), + setup: func() { + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(**Subscriptions); ok { + *v = &Subscriptions{ + Repositories: map[string][]*Subscription{ + "mockorg/mockrepo": { + { + ChannelID: MockChannelID, + CreatorID: MockCreatorID, + Features: Features("pulls,label:\"validLabel\""), + Repository: MockRepo, + }, + }, + }, + } + } + return nil + }).Times(1) + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(nil, &model.AppError{Message: "error creating post"}).Times(1) + mockAPI.On("LogWarn", "Error webhook post", "post", mock.AnythingOfType("*model.Post"), "error", "error creating post") + }, + }, + { + name: "event label is not equal to subscription label", + event: GetMockPullRequestEvent(actionLabeled, MockRepo, "invalidLabel", false, MockSender, MockUserID, MockUsername), + setup: func() { + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(**Subscriptions); ok { + *v = GetMockSubscriptionWithLabel("mockorg/mockrepo", "pulls,label:\"validLabel\"") + } + return nil + }).Times(1) + }, + }, + { + name: "success creating post for action labeled", + event: GetMockPullRequestEvent(actionLabeled, MockRepo, MockValidLabel, false, MockSender, MockUserID, MockUsername), + setup: func() { + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(**Subscriptions); ok { + *v = &Subscriptions{ + Repositories: map[string][]*Subscription{ + "mockorg/mockrepo": { + { + ChannelID: MockChannelID, + CreatorID: MockCreatorID, + Features: Features("pulls,label:\"validLabel\""), + Repository: MockRepo, + }, + }, + }, + } + } + return nil + }).Times(1) + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, nil).Times(1) + }, + }, + { + name: "Success creating post for pull requeset opened", + event: GetMockPullRequestEvent(actionOpened, MockRepo, MockValidLabel, false, MockSender, MockUserID, MockUsername), + setup: func() { + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(**Subscriptions); ok { + *v = GetMockSubscriptionWithLabel("mockorg/mockrepo", "pulls_created,label:\"validLabel\"") + } + return nil + }).Times(1) + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, nil).Times(1) + }, + }, + { + name: "Success creating post for pull opened", + event: GetMockPullRequestEvent(actionReopened, MockRepo, MockValidLabel, false, MockSender, MockUserID, MockUsername), + setup: func() { + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(**Subscriptions); ok { + *v = GetMockSubscriptionWithLabel("mockorg/mockrepo", "pulls,label:\"validLabel\"") + } + return nil + }).Times(1) + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, nil).Times(1) + }, + }, + { + name: "Success creating post for action MarkedReadyForReview", + event: GetMockPullRequestEvent(actionMarkedReadyForReview, MockRepo, MockValidLabel, false, MockSender, MockUserID, MockUsername), + setup: func() { + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(**Subscriptions); ok { + *v = GetMockSubscriptionWithLabel("mockorg/mockrepo", "pulls,label:\"validLabel\"") + } + return nil + }).Times(1) + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, nil).Times(1) + }, + }, + { + name: "Success creating post for action closed", + event: GetMockPullRequestEvent(actionClosed, MockRepo, MockValidLabel, false, MockSender, MockUserID, MockUsername), + setup: func() { + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(**Subscriptions); ok { + *v = GetMockSubscriptionWithLabel("mockorg/mockrepo", "pulls,label:\"validLabel\"") + } + return nil + }).Times(1) + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, nil).Times(1) + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.setup() + + p.postPullRequestEvent(tc.event) + + mockAPI.AssertExpectations(t) + }) + } +} + +func TestSanitizeDescription(t *testing.T) { + tests := []struct { + name string + description string + expected string + }{ + { + name: "description with
", + description: "description with
MockDetails
and the values", + expected: "description with and the values", + }, + { + name: "description without
", + description: "Content without details tag.", + expected: "Content without details tag.", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) + p := getPluginTest(mockAPI, mockKvStore) + + sanitizedDescription := p.sanitizeDescription(tt.description) + + assert.Equal(t, tt.expected, sanitizedDescription) + }) + } +} + +func TestHandlePRDescriptionMentionNotification(t *testing.T) { + mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) + p := getPluginTest(mockAPI, mockKvStore) + + tests := []struct { + name string + event *github.PullRequestEvent + setup func() + }{ + { + name: "action other than opened", + event: GetMockPRDescriptionEvent(MockRepo, MockOrg, MockSender, MockSender, actionClosed, ""), + setup: func() {}, + }, + { + name: "no mentioned users in PR description", + event: GetMockPRDescriptionEvent(MockRepo, MockOrg, MockSender, MockSender, actionOpened, ""), + setup: func() {}, + }, + { + name: "PR description mentions a user but they are the PR author", + event: GetMockPRDescriptionEvent(MockRepo, MockOrg, MockSender, MockSender, actionOpened, fmt.Sprintf("@%s", MockSender)), + setup: func() { + mockKvStore.EXPECT().Get("prAuthor_githubusername", gomock.Any()).Return(nil).Times(1) + }, + }, + { + name: "Skip notification for pull request", + event: GetMockPRDescriptionEvent(MockRepo, MockOrg, "mockSender2", MockSender, actionOpened, fmt.Sprintf("@%s", MockSender)), + setup: func() { + mockKvStore.EXPECT().Get("prAuthor_githubusername", gomock.Any()).Return(nil).Times(1) + }, + }, + { + name: "user id not mapped with github", + event: GetMockPRDescriptionEvent(MockRepo, MockOrg, MockSender, MockSender, actionOpened, MockProfileUsername), + setup: func() { + mockKvStore.EXPECT().Get("username_githubusername", gomock.Any()).Return(nil).Times(1) + }, + }, + { + name: "Error getting channel", + event: GetMockPRDescriptionEvent(MockRepo, MockOrg, MockSender, MockSender, actionOpened, MockProfileUsername), + setup: func() { + mockKvStore.EXPECT().Get("username_githubusername", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(*[]byte); ok { + *v = []byte(MockUserID) + } + return nil + }).Times(1) + mockKvStore.EXPECT().Get("mockUserID_githubtoken", gomock.Any()).Return(nil).Times(1) + mockAPI.On("GetDirectChannel", MockUserID, p.BotUserID).Return(nil, &model.AppError{Message: "error getting direct channel"}).Times(1) + }, + }, + { + name: "PR description mentions a user, post created", + event: GetMockPRDescriptionEvent(MockRepo, MockOrg, MockSender, MockSender, actionOpened, MockProfileUsername), + setup: func() { + mockKvStore.EXPECT().Get("username_githubusername", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(*[]byte); ok { + *v = []byte(MockUserID) + } + return nil + }).Times(1) + mockKvStore.EXPECT().Get("mockUserID_githubtoken", gomock.Any()).Return(nil).Times(1) + mockAPI.On("GetDirectChannel", MockUserID, p.BotUserID).Return(&model.Channel{Id: MockChannelID}, nil).Times(1) + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{Id: MockPostID}, nil).Times(1) + mockAPI.On("LogWarn", "Failed to get github user info", "error", "Must connect user account to GitHub first.") + }, + }, + { + name: "Error creating post", + event: GetMockPRDescriptionEvent(MockRepo, MockOrg, MockSender, MockSender, actionOpened, MockProfileUsername), + setup: func() { + mockKvStore.EXPECT().Get("username_githubusername", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(*[]byte); ok { + *v = []byte(MockUserID) + } + return nil + }).Times(1) + mockKvStore.EXPECT().Get("mockUserID_githubtoken", gomock.Any()).Return(nil).Times(1) + mockAPI.On("GetDirectChannel", MockUserID, p.BotUserID).Return(&model.Channel{Id: MockChannelID}, nil).Times(1) + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(nil, &model.AppError{Message: "error creating post"}).Times(1) + mockAPI.On("LogWarn", "Failed to get github user info", "error", "Must connect user account to GitHub first.") + mockAPI.On("LogWarn", "Error webhook post", "post", mock.AnythingOfType("*model.Post"), "error", "error creating post") + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.setup() + + p.handlePRDescriptionMentionNotification(tc.event) + + mockAPI.AssertExpectations(t) + }) + } +} + +func TestPostIssueEvent(t *testing.T) { + mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) + p := getPluginTest(mockAPI, mockKvStore) + + tests := []struct { + name string + event *github.IssuesEvent + setup func() + }{ + { + name: "no subscribed channels for repository", + event: GetMockIssueEvent(MockRepo, MockOrg, MockSender, actionOpened, MockLabel), + setup: func() { + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).Return(nil).Times(1) + }, + }, + { + name: "issue labeled but recently created, no post sent", + event: GetMockIssueEventWithTimeDiff(MockRepo, MockOrg, MockSender, actionLabeled, MockLabel, -2*time.Second), + setup: func() {}, + }, + { + name: "issue labeled with matching label", + event: GetMockIssueEventWithTimeDiff(MockRepo, MockOrg, MockSender, actionLabeled, MockValidLabel, -5*time.Second), + setup: func() { + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(**Subscriptions); ok { + *v = GetMockSubscriptionWithLabel("mockrepo/mockorg", "issues,label:\"validLabel\"") + } + return nil + }).Times(1) + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, nil).Times(1) + }, + }, + { + name: "error creating post", + event: GetMockIssueEventWithTimeDiff(MockRepo, MockOrg, MockSender, actionLabeled, MockValidLabel, -5*time.Second), + setup: func() { + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(**Subscriptions); ok { + *v = GetMockSubscriptionWithLabel("mockrepo/mockorg", "issues,label:\"validLabel\"") + } + return nil + }).Times(1) + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(nil, &model.AppError{Message: "error creating post"}).Times(1) + mockAPI.On("LogWarn", "Error webhook post", "post", mock.AnythingOfType("*model.Post"), "error", "error creating post") + }, + }, + { + name: "issue creation skipped due to unsupported action", + event: GetMockIssueEventWithTimeDiff(MockRepo, MockOrg, MockSender, actionClosed, MockLabel, -5*time.Second), + setup: func() { + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(**Subscriptions); ok { + *v = GetMockSubscriptionWithLabel("mockorg/mockrepo", featureIssueCreation) + } + return nil + }).Times(1) + }, + }, + { + name: "issue skipped due to unmatched label", + event: GetMockIssueEventWithTimeDiff(MockRepo, MockOrg, MockSender, actionLabeled, "nonMatchingLabel", -5*time.Second), + setup: func() { + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(**Subscriptions); ok { + *v = GetMockSubscriptionWithLabel("mockorg/mockrepo", "issues,label:\"validLabel\"") + } + return nil + }).Times(1) + }, + }, + { + name: "issue skipped due to mismatched event label", + event: func() *github.IssuesEvent { + event := GetMockIssueEventWithTimeDiff(MockRepo, MockOrg, MockSender, actionLabeled, "eventLabel", -5*time.Second) + event.GetIssue().Labels = []*github.Label{{Name: github.String("subscriptionLabel")}} + return event + }(), + setup: func() { + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(**Subscriptions); ok { + *v = GetMockSubscriptionWithLabel("mockorg/mockrepo", "issues,label:\"subscriptionLabel\"") + } + return nil + }).Times(1) + }, + }, + { + name: "success creating post for issue opened", + event: GetMockIssueEvent(MockRepo, MockOrg, MockSender, actionOpened, MockLabel), + setup: func() { + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(**Subscriptions); ok { + *v = GetMockSubscriptionWithLabel("mockrepo/mockorg", featureIssueCreation) + } + return nil + }).Times(1) + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, nil).Times(1) + }, + }, + { + name: "success creating post for issue closed", + event: GetMockIssueEvent(MockRepo, MockOrg, MockSender, actionOpened, MockLabel), + setup: func() { + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(**Subscriptions); ok { + *v = GetMockSubscriptionWithLabel("mockrepo/mockorg", featureIssueCreation) + } + return nil + }).Times(1) + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, nil).Times(1) + }, + }, + { + name: "success creating post for issue reopened", + event: GetMockIssueEvent(MockRepo, MockOrg, MockSender, actionReopened, MockLabel), + setup: func() { + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(**Subscriptions); ok { + *v = GetMockSubscriptionWithLabel("mockrepo/mockorg", featureIssueCreation) + } + return nil + }).Times(1) + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, nil).Times(1) + }, + }, + { + name: "unsupported action", + event: GetMockIssueEvent(MockRepo, MockOrg, MockSender, actionDeleted, MockLabel), + setup: func() { + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(**Subscriptions); ok { + *v = GetMockSubscriptionWithLabel("mockorg/mockrepo", featureIssueCreation) + } + return nil + }).Times(1) + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.setup() + + p.postIssueEvent(tc.event) + + mockAPI.AssertExpectations(t) + mockAPI.ExpectedCalls = nil + }) + } +} + func TestPostPushEvent(t *testing.T) { mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) p := getPluginTest(mockAPI, mockKvStore) tests := []struct { - name string - pushEvent *github.PushEvent - setup func() + name string + pushEvent *github.PushEvent + setup func() + }{ + { + name: "no subscription found", + pushEvent: GetMockPushEvent(), + setup: func() { + mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).Return(nil).Times(1) + }, + }, + { + name: "no commits found in event", + pushEvent: GetMockPushEventWithoutCommit(), + setup: func() { + mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(**Subscriptions); ok { + *v = GetMockSubscriptions() + } + return nil + }).Times(1) + }, + }, + { + name: "Error creating post", + pushEvent: GetMockPushEvent(), + setup: func() { + mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(**Subscriptions); ok { + *v = GetMockSubscriptions() + } + return nil + }).Times(1) + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(nil, &model.AppError{Message: "error creating post"}).Times(1) + mockAPI.On("LogWarn", "Error webhook post", "post", mock.AnythingOfType("*model.Post"), "error", "error creating post") + }, + }, + { + name: "Successful handle post push event", + pushEvent: GetMockPushEvent(), + setup: func() { + mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(**Subscriptions); ok { + *v = GetMockSubscriptions() + } + return nil + }).Times(1) + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, nil).Times(1) + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.setup() + + p.postPushEvent(tc.pushEvent) + + mockAPI.AssertExpectations(t) + }) + } +} + +func TestPostCreateEvent(t *testing.T) { + mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) + p := getPluginTest(mockAPI, mockKvStore) + + tests := []struct { + name string + createEvent *github.CreateEvent + setup func() + }{ + { + name: "no subscription found", + createEvent: GetMockCreateEvent(), + setup: func() { + mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).Return(nil).Times(1) + }, + }, + { + name: "unsupported ref type", + createEvent: GetMockCreateEventWithUnsupportedRefType(), + setup: func() { + mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(**Subscriptions); ok { + *v = GetMockSubscriptions() + } + return nil + }).Times(1) + }, + }, + { + name: "Error creating post", + createEvent: GetMockCreateEvent(), + setup: func() { + mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(**Subscriptions); ok { + *v = GetMockSubscriptions() + } + return nil + }).Times(1) + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(nil, &model.AppError{Message: "error creating post"}).Times(1) + mockAPI.On("LogWarn", "Error webhook post", "post", mock.AnythingOfType("*model.Post"), "error", "error creating post") + }, + }, + { + name: "Successfully handle post create event", + createEvent: GetMockCreateEvent(), + setup: func() { + mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(**Subscriptions); ok { + *v = GetMockSubscriptions() + } + return nil + }).Times(1) + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, nil).Times(1) + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.setup() + + p.postCreateEvent(tc.createEvent) + + mockAPI.AssertExpectations(t) + }) + } +} + +func TestPostDeleteEvent(t *testing.T) { + mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) + p := getPluginTest(mockAPI, mockKvStore) + + tests := []struct { + name string + deleteEvent *github.DeleteEvent + setup func() + }{ + { + name: "no subscription found", + deleteEvent: GetMockDeleteEvent(), + setup: func() { + mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).Return(nil).Times(1) + }, + }, + { + name: "non-tag and non-branch event", + deleteEvent: GetMockDeleteEventWithInvalidType(), + setup: func() { + mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(**Subscriptions); ok { + *v = GetMockSubscriptions() + } + return nil + }).Times(1) + }, + }, + { + name: "Error creating post", + deleteEvent: GetMockDeleteEvent(), + setup: func() { + mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(**Subscriptions); ok { + *v = GetMockSubscriptions() + } + return nil + }).Times(1) + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(nil, &model.AppError{Message: "error creating post"}).Times(1) + mockAPI.On("LogWarn", "Error webhook post", "post", mock.AnythingOfType("*model.Post"), "error", "error creating post") + }, + }, + { + name: "Successful handle post delete event", + deleteEvent: GetMockDeleteEvent(), + setup: func() { + mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(**Subscriptions); ok { + *v = GetMockSubscriptions() + } + return nil + }).Times(1) + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, nil).Times(1) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.setup() + + p.postDeleteEvent(tc.deleteEvent) + + mockAPI.AssertExpectations(t) + }) + } +} + +func TestPostIssueCommentEvent(t *testing.T) { + mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) + p := getPluginTest(mockAPI, mockKvStore) + + tests := []struct { + name string + event *github.IssueCommentEvent + setup func() + expectedErr string }{ { - name: "no subscription found", - pushEvent: GetMockPushEvent(), + name: "no subscriptions found", + event: GetMockIssueCommentEvent(actionCreated, "mockBody", "mockUser"), setup: func() { mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).Return(nil).Times(1) }, }, { - name: "no commits found in event", - pushEvent: GetMockPushEventWithoutCommit(), + name: "event action is not created", + event: GetMockIssueCommentEvent("edited", "mockBody", "mockUser"), setup: func() { mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).DoAndReturn(func(key string, value interface{}) error { if v, ok := value.(**Subscriptions); ok { @@ -40,8 +1357,8 @@ func TestPostPushEvent(t *testing.T) { }, }, { - name: "Error creating post", - pushEvent: GetMockPushEvent(), + name: "successful event handling with no label filtering", + event: GetMockIssueCommentEvent(actionCreated, "mockBody", "mockUser"), setup: func() { mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).DoAndReturn(func(key string, value interface{}) error { if v, ok := value.(**Subscriptions); ok { @@ -49,13 +1366,26 @@ func TestPostPushEvent(t *testing.T) { } return nil }).Times(1) - mockAPI.On("CreatePost", mock.Anything).Return(nil, &model.AppError{Message: "error creating post"}).Times(1) - mockAPI.On("LogWarn", "Error webhook post", "post", mock.Anything, "error", "error creating post") + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, nil).Times(1) }, }, { - name: "Successful handle post push event", - pushEvent: GetMockPushEvent(), + name: "error creating post", + event: GetMockIssueCommentEvent(actionCreated, "mockBody", "mockUser"), + setup: func() { + mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(**Subscriptions); ok { + *v = GetMockSubscriptions() + } + return nil + }).Times(1) + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(nil, &model.AppError{Message: "error creating post"}).Times(1) + mockAPI.On("LogWarn", "Error webhook post", "post", mock.AnythingOfType("*model.Post"), "error", "error creating post").Times(1) + }, + }, + { + name: "successful handle post issue comment event", + event: GetMockIssueCommentEvent(actionCreated, "mockBody", "mockUser"), setup: func() { mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).DoAndReturn(func(key string, value interface{}) error { if v, ok := value.(**Subscriptions); ok { @@ -63,40 +1393,103 @@ func TestPostPushEvent(t *testing.T) { } return nil }).Times(1) - mockAPI.On("CreatePost", mock.Anything).Return(&model.Post{}, nil).Times(1) + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, nil).Times(1) }, }, } + for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { tc.setup() - p.postPushEvent(tc.pushEvent) + p.postIssueCommentEvent(tc.event) mockAPI.AssertExpectations(t) }) } } -func TestPostCreateEvent(t *testing.T) { +func TestSenderMutedByReceiver(t *testing.T) { + mockStore, mockAPI, _, _, _ := GetTestSetup(t) + p := getPluginTest(mockAPI, mockStore) + + tests := []struct { + name string + userID string + sender string + setup func() + assert func(t *testing.T, muted bool) + }{ + { + name: "sender is muted", + userID: "user1", + sender: "sender1", + setup: func() { + mockStore.EXPECT().Get("user1-muted-users", gomock.Any()).Return(nil).Do(func(key string, value interface{}) { + *value.(*[]byte) = []byte("sender1,sender2") + }).Times(1) + }, + assert: func(t *testing.T, muted bool) { + assert.True(t, muted, "Expected sender to be muted") + }, + }, + { + name: "sender is not muted", + userID: "user1", + sender: "sender3", + setup: func() { + mockStore.EXPECT().Get("user1-muted-users", gomock.Any()).Return(nil).Do(func(key string, value interface{}) { + *value.(*[]byte) = []byte("sender1,sender2") + }).Times(1) + }, + assert: func(t *testing.T, muted bool) { + assert.False(t, muted, "Expected sender to not be muted") + }, + }, + { + name: "error fetching muted users", + userID: "user1", + sender: "sender1", + setup: func() { + mockStore.EXPECT().Get("user1-muted-users", gomock.Any()).Return(errors.New("store error")).Times(1) + mockAPI.On("LogWarn", "Failed to get muted users", "userID", "user1").Times(1) + }, + assert: func(t *testing.T, muted bool) { + assert.False(t, muted, "Expected sender to not be muted due to store error") + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.setup() + + muted := p.senderMutedByReceiver(tc.userID, tc.sender) + + tc.assert(t, muted) + mockAPI.AssertExpectations(t) + }) + } +} + +func TestPostPullRequestReviewEvent(t *testing.T) { mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) p := getPluginTest(mockAPI, mockKvStore) tests := []struct { - name string - createEvent *github.CreateEvent - setup func() + name string + event *github.PullRequestReviewEvent + setup func() }{ { - name: "no subscription found", - createEvent: GetMockCreateEvent(), + name: "no subscriptions found", + event: GetMockPullRequestReviewEvent("submitted", "approved", MockRepoName, false, MockUserLogin, MockIssueAuthor), setup: func() { mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).Return(nil).Times(1) }, }, { - name: "unsupported ref type", - createEvent: GetMockCreateEventWithUnsupportedRefType(), + name: "unsupported action in event", + event: GetMockPullRequestReviewEvent("deleted", "approved", MockRepoName, false, MockUserLogin, MockIssueAuthor), setup: func() { mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).DoAndReturn(func(key string, value interface{}) error { if v, ok := value.(**Subscriptions); ok { @@ -107,8 +1500,8 @@ func TestPostCreateEvent(t *testing.T) { }, }, { - name: "Error creating post", - createEvent: GetMockCreateEvent(), + name: "unsupported review state", + event: GetMockPullRequestReviewEvent("submitted", "canceled", MockRepoName, false, MockUserLogin, MockIssueAuthor), setup: func() { mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).DoAndReturn(func(key string, value interface{}) error { if v, ok := value.(**Subscriptions); ok { @@ -116,13 +1509,26 @@ func TestPostCreateEvent(t *testing.T) { } return nil }).Times(1) - mockAPI.On("CreatePost", mock.Anything).Return(nil, &model.AppError{Message: "error creating post"}).Times(1) - mockAPI.On("LogWarn", "Error webhook post", "post", mock.Anything, "error", "error creating post") + mockAPI.On("LogDebug", "Unhandled review state", "state", "canceled").Times(1) }, }, { - name: "Successfully handle post create event", - createEvent: GetMockCreateEvent(), + name: "error creating post", + event: GetMockPullRequestReviewEvent("submitted", "approved", MockRepoName, false, MockUserLogin, MockIssueAuthor), + setup: func() { + mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(**Subscriptions); ok { + *v = GetMockSubscriptions() + } + return nil + }).Times(1) + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(nil, &model.AppError{Message: "error creating post"}).Times(1) + mockAPI.On("LogWarn", "Error webhook post", "post", mock.AnythingOfType("*model.Post"), "error", "error creating post").Times(1) + }, + }, + { + name: "successful handling of pull request review event", + event: GetMockPullRequestReviewEvent("submitted", "approved", MockRepoName, false, MockUserLogin, MockIssueAuthor), setup: func() { mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).DoAndReturn(func(key string, value interface{}) error { if v, ok := value.(**Subscriptions); ok { @@ -130,40 +1536,41 @@ func TestPostCreateEvent(t *testing.T) { } return nil }).Times(1) - mockAPI.On("CreatePost", mock.Anything).Return(&model.Post{}, nil).Times(1) + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, nil).Times(1) }, }, } + for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { tc.setup() - p.postCreateEvent(tc.createEvent) + p.postPullRequestReviewEvent(tc.event) mockAPI.AssertExpectations(t) }) } } -func TestPostDeleteEvent(t *testing.T) { +func TestPostPullRequestReviewCommentEvent(t *testing.T) { mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) p := getPluginTest(mockAPI, mockKvStore) tests := []struct { - name string - deleteEvent *github.DeleteEvent - setup func() + name string + event *github.PullRequestReviewCommentEvent + setup func() }{ { - name: "no subscription found", - deleteEvent: GetMockDeleteEvent(), + name: "no subscriptions found", + event: GetMockPullRequestReviewCommentEvent(), setup: func() { mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).Return(nil).Times(1) }, }, { - name: "non-tag and non-branch event", - deleteEvent: GetMockDeleteEventWithInvalidType(), + name: "error creating post", + event: GetMockPullRequestReviewCommentEvent(), setup: func() { mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).DoAndReturn(func(key string, value interface{}) error { if v, ok := value.(**Subscriptions); ok { @@ -171,11 +1578,13 @@ func TestPostDeleteEvent(t *testing.T) { } return nil }).Times(1) + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(nil, &model.AppError{Message: "error creating post"}).Times(1) + mockAPI.On("LogWarn", "Error webhook post", "post", mock.AnythingOfType("*model.Post"), "error", "error creating post").Times(1) }, }, { - name: "Error creating post", - deleteEvent: GetMockDeleteEvent(), + name: "successful handling of pull request review comment event", + event: GetMockPullRequestReviewCommentEvent(), setup: func() { mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).DoAndReturn(func(key string, value interface{}) error { if v, ok := value.(**Subscriptions); ok { @@ -183,165 +1592,194 @@ func TestPostDeleteEvent(t *testing.T) { } return nil }).Times(1) - mockAPI.On("CreatePost", mock.Anything).Return(nil, &model.AppError{Message: "error creating post"}).Times(1) - mockAPI.On("LogWarn", "Error webhook post", "post", mock.Anything, "error", "error creating post") + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, nil).Times(1) + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.setup() + + p.postPullRequestReviewCommentEvent(tc.event) + + mockAPI.AssertExpectations(t) + }) + } +} + +func TestHandleCommentMentionNotification(t *testing.T) { + mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) + p := getPluginTest(mockAPI, mockKvStore) + + tests := []struct { + name string + event *github.IssueCommentEvent + setup func() + }{ + { + name: "unsupported action", + event: GetMockIssueCommentEvent(actionEdited, "mockBody", "mockUser"), + setup: func() {}, + }, + { + name: "commenter is the same as mentioned user", + event: GetMockIssueCommentEvent(actionCreated, "mention @mockUser", "mockUser"), + setup: func() {}, + }, + { + name: "comment mentions issue author", + event: GetMockIssueCommentEvent(actionCreated, "mention @issueAuthor", "mockUser"), + setup: func() {}, + }, + { + name: "error getting channel details", + event: GetMockIssueCommentEvent(actionCreated, "mention @otherUser", "mockUser"), + setup: func() { + mockKvStore.EXPECT().Get("otherUser_githubusername", gomock.Any()).Return(nil).Times(1) + }, + }, + { + name: "error getting channel details", + event: GetMockIssueCommentEvent(actionCreated, "mention @otherUser", "mockUser"), + setup: func() { + mockKvStore.EXPECT().Get("otherUser_githubusername", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(*[]byte); ok { + *v = []byte("otherUserID") + } + return nil + }).Times(1) + mockKvStore.EXPECT().Get("otherUserID_githubtoken", gomock.Any()).Return(nil).Times(1) + mockAPI.On("GetDirectChannel", "otherUserID", "mockBotID").Return(nil, &model.AppError{Message: "error getting channel"}).Times(1) }, }, { - name: "Successful handle post delete event", - deleteEvent: GetMockDeleteEvent(), + name: "error creating post", + event: GetMockIssueCommentEvent(actionCreated, "mention @otherUser", "mockUser"), setup: func() { - mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).DoAndReturn(func(key string, value interface{}) error { - if v, ok := value.(**Subscriptions); ok { - *v = GetMockSubscriptions() + mockKvStore.EXPECT().Get("otherUser_githubusername", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(*[]byte); ok { + *v = []byte("otherUserID") + } + return nil + }).Times(1) + mockKvStore.EXPECT().Get("otherUserID_githubtoken", gomock.Any()).Return(nil).Times(1) + mockAPI.On("GetDirectChannel", "otherUserID", "mockBotID").Return(&model.Channel{Id: "mockChannelID"}, nil).Times(1) + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(nil, &model.AppError{Message: "error creating post"}).Times(1) + mockAPI.On("LogWarn", "Error creating mention post", "error", "error creating post").Times(1) + mockAPI.On("LogWarn", "Failed to get github user info", "error", "Must connect user account to GitHub first.").Times(1) + }, + }, + { + name: "successful mention notification", + event: GetMockIssueCommentEvent(actionCreated, "mention @otherUser", "mockUser"), + setup: func() { + mockKvStore.EXPECT().Get("otherUser_githubusername", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(*[]byte); ok { + *v = []byte("otherUserID") } return nil }).Times(1) - mockAPI.On("CreatePost", mock.Anything).Return(&model.Post{}, nil).Times(1) + mockKvStore.EXPECT().Get("otherUserID_githubtoken", gomock.Any()).Return(nil).Times(1) + mockAPI.On("GetDirectChannel", "otherUserID", "mockBotID").Return(&model.Channel{Id: "mockChannelID"}, nil).Times(1) + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, nil).Times(1) + mockAPI.On("LogWarn", "Failed to get github user info", "error", "Must connect user account to GitHub first.") }, }, } - for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { tc.setup() - p.postDeleteEvent(tc.deleteEvent) + p.handleCommentMentionNotification(tc.event) mockAPI.AssertExpectations(t) }) } } -func TestPostIssueCommentEvent(t *testing.T) { +func TestHandleCommentAuthorNotification(t *testing.T) { mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) p := getPluginTest(mockAPI, mockKvStore) tests := []struct { - name string - event *github.IssueCommentEvent - setup func() - expectedErr string + name string + event *github.IssueCommentEvent + setup func() }{ { - name: "no subscriptions found", - event: GetMockIssueCommentEvent(actionCreated, "mockBody", "mockUser"), - setup: func() { - mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).Return(nil).Times(1) - }, + name: "author is the commenter", + event: GetMockIssueCommentEvent(actionCreated, "mockBody", "issueAuthor"), + setup: func() {}, }, { - name: "event action is not created", - event: GetMockIssueCommentEvent("edited", "mockBody", "mockUser"), - setup: func() { - mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).DoAndReturn(func(key string, value interface{}) error { - if v, ok := value.(**Subscriptions); ok { - *v = GetMockSubscriptions() - } - return nil - }).Times(1) - }, + name: "unsupported action", + event: GetMockIssueCommentEvent(actionEdited, "mockBody", "mockUser"), + setup: func() {}, }, { - name: "successful event handling with no label filtering", + name: "author not mapped to user ID", event: GetMockIssueCommentEvent(actionCreated, "mockBody", "mockUser"), setup: func() { - mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).DoAndReturn(func(key string, value interface{}) error { - if v, ok := value.(**Subscriptions); ok { - *v = GetMockSubscriptions() - } - return nil - }).Times(1) - mockAPI.On("CreatePost", mock.Anything).Return(&model.Post{}, nil).Times(1) + mockKvStore.EXPECT().Get("issueAuthor_githubusername", gomock.Any()).Return(nil).Times(1) }, }, { - name: "error creating post", + name: "author has no permission to repo", event: GetMockIssueCommentEvent(actionCreated, "mockBody", "mockUser"), setup: func() { - mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).DoAndReturn(func(key string, value interface{}) error { - if v, ok := value.(**Subscriptions); ok { - *v = GetMockSubscriptions() + mockKvStore.EXPECT().Get("issueAuthor_githubusername", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(*[]byte); ok { + *v = []byte("authorUserID") } return nil }).Times(1) - mockAPI.On("CreatePost", mock.Anything).Return(nil, &model.AppError{Message: "error creating post"}).Times(1) - mockAPI.On("LogWarn", "Error webhook post", "post", mock.Anything, "error", "error creating post").Times(1) }, }, { - name: "successful handle post issue comment event", - event: GetMockIssueCommentEvent(actionCreated, "mockBody", "mockUser"), + name: "unhandled issue type", + event: GetMockIssueCommentEventWithURL(actionCreated, "mockBody", "mockUser", "https://mockurl.com/unhandledType/123"), setup: func() { - mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).DoAndReturn(func(key string, value interface{}) error { - if v, ok := value.(**Subscriptions); ok { - *v = GetMockSubscriptions() + mockKvStore.EXPECT().Get("issueAuthor_githubusername", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(*[]byte); ok { + *v = []byte("authorUserID") } return nil }).Times(1) - mockAPI.On("CreatePost", mock.Anything).Return(&model.Post{}, nil).Times(1) + mockAPI.On("LogDebug", "Unhandled issue type", "type", "unhandledType").Times(1) }, }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - tc.setup() - - p.postIssueCommentEvent(tc.event) - - mockAPI.AssertExpectations(t) - }) - } -} - -func TestSenderMutedByReceiver(t *testing.T) { - mockStore, mockAPI, _, _, _ := GetTestSetup(t) - p := getPluginTest(mockAPI, mockStore) - - tests := []struct { - name string - userID string - sender string - setup func() - assert func(t *testing.T, muted bool) - }{ { - name: "sender is muted", - userID: "user1", - sender: "sender1", + name: "error creating post", + event: GetMockIssueCommentEventWithURL(actionCreated, "mockBody", "mockUser", "https://mockurl.com/issues/123"), setup: func() { - mockStore.EXPECT().Get("user1-muted-users", gomock.Any()).Return(nil).Do(func(key string, value interface{}) { - *value.(*[]byte) = []byte("sender1,sender2") + mockKvStore.EXPECT().Get("issueAuthor_githubusername", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(*[]byte); ok { + *v = []byte("authorUserID") + } + return nil }).Times(1) - }, - assert: func(t *testing.T, muted bool) { - assert.True(t, muted, "Expected sender to be muted") + mockKvStore.EXPECT().Get("authorUserID-muted-users", gomock.Any()).Return(nil).Times(1) + mockKvStore.EXPECT().Get("authorUserID_githubtoken", gomock.Any()).Return(nil).Times(1) + mockAPI.On("GetDirectChannel", "authorUserID", "mockBotID").Return(&model.Channel{Id: "mockChannelID"}, nil).Times(1) + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, nil, &model.AppError{Message: "error creating post"}).Times(1) + mockAPI.On("LogWarn", "Failed to get github user info", "error", "Must connect user account to GitHub first.").Times(1) }, }, { - name: "sender is not muted", - userID: "user1", - sender: "sender3", + name: "successful notification", + event: GetMockIssueCommentEventWithURL(actionCreated, "mockBody", "mockUser", "https://mockurl.com/issues/123"), setup: func() { - mockStore.EXPECT().Get("user1-muted-users", gomock.Any()).Return(nil).Do(func(key string, value interface{}) { - *value.(*[]byte) = []byte("sender1,sender2") + mockKvStore.EXPECT().Get("issueAuthor_githubusername", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(*[]byte); ok { + *v = []byte("authorUserID") + } + return nil }).Times(1) - }, - assert: func(t *testing.T, muted bool) { - assert.False(t, muted, "Expected sender to not be muted") - }, - }, - { - name: "error fetching muted users", - userID: "user1", - sender: "sender1", - setup: func() { - mockStore.EXPECT().Get("user1-muted-users", gomock.Any()).Return(errors.New("store error")).Times(1) - mockAPI.On("LogWarn", "Failed to get muted users", "userID", "user1").Times(1) - }, - assert: func(t *testing.T, muted bool) { - assert.False(t, muted, "Expected sender to not be muted due to store error") + mockKvStore.EXPECT().Get("authorUserID-muted-users", gomock.Any()).Return(nil).Times(1) + mockKvStore.EXPECT().Get("authorUserID_githubtoken", gomock.Any()).Return(nil).Times(1) + mockAPI.On("LogWarn", "Failed to get github user info", "error", "Must connect user account to GitHub first.").Times(1) + mockAPI.On("GetDirectChannel", "authorUserID", "mockBotID").Return(&model.Channel{Id: "mockChannelID"}, nil).Times(1) + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, nil).Times(1) }, }, } @@ -349,136 +1787,185 @@ func TestSenderMutedByReceiver(t *testing.T) { t.Run(tc.name, func(t *testing.T) { tc.setup() - muted := p.senderMutedByReceiver(tc.userID, tc.sender) + p.handleCommentAuthorNotification(tc.event) - tc.assert(t, muted) mockAPI.AssertExpectations(t) }) } } -func TestPostPullRequestReviewEvent(t *testing.T) { +func TestHandleCommentAssigneeNotification(t *testing.T) { mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) p := getPluginTest(mockAPI, mockKvStore) tests := []struct { name string - event *github.PullRequestReviewEvent + event *github.IssueCommentEvent setup func() }{ { - name: "no subscriptions found", - event: GetMockPullRequestReviewEvent("submitted", "approved"), + name: "unsupported issue type", + event: GetMockIssueCommentEventWithAssignees("mockType", actionCreated, "mockBody", "mockUser", []string{"assigneeUser"}), setup: func() { - mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).Return(nil).Times(1) + mockAPI.On("LogDebug", "Unhandled issue type", "Type", "mockType") }, }, { - name: "unsupported action in event", - event: GetMockPullRequestReviewEvent("deleted", "approved"), + name: "assignee is the author", + event: GetMockIssueCommentEventWithAssignees("issues", actionCreated, "mockBody", "assigneeUser", []string{"assigneeUser"}), setup: func() { - mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).DoAndReturn(func(key string, value interface{}) error { - if v, ok := value.(**Subscriptions); ok { - *v = GetMockSubscriptions() - } - return nil - }).Times(1) + mockKvStore.EXPECT().Get("assigneeUser_githubusername", gomock.Any()).Return(nil).Times(1) }, }, { - name: "unsupported review state", - event: GetMockPullRequestReviewEvent("submitted", "canceled"), + name: "issue author is assignee", + event: GetMockIssueCommentEventWithAssignees("issues", actionCreated, "mockBody", "assigneeUser", []string{"issueAuthor"}), setup: func() { - mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).DoAndReturn(func(key string, value interface{}) error { - if v, ok := value.(**Subscriptions); ok { - *v = GetMockSubscriptions() + mockKvStore.EXPECT().Get("issueAuthor_githubusername", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(*[]byte); ok { + *v = []byte("issueAuthor") } return nil }).Times(1) - mockAPI.On("LogDebug", "Unhandled review state", "state", "canceled").Times(1) }, }, { - name: "error creating post", - event: GetMockPullRequestReviewEvent("submitted", "approved"), + name: "assignee is the sender", + event: GetMockIssueCommentEventWithAssignees("issues", actionCreated, "mockBody", "mockUser", []string{"mockUser"}), setup: func() { - mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).DoAndReturn(func(key string, value interface{}) error { - if v, ok := value.(**Subscriptions); ok { - *v = GetMockSubscriptions() + mockKvStore.EXPECT().Get("mockUser_githubusername", gomock.Any()).Return(nil).Times(1) + }, + }, + { + name: "comment mentions assignee (self-mention)", + event: GetMockIssueCommentEventWithAssignees("issues", actionCreated, "mention @assigneeUser", "mockUser", []string{"assigneeUser"}), + setup: func() { + mockKvStore.EXPECT().Get("assigneeUser_githubusername", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(*[]byte); ok { + *v = []byte("assigneeUserID") } return nil }).Times(1) - mockAPI.On("CreatePost", mock.Anything).Return(nil, &model.AppError{Message: "error creating post"}).Times(1) - mockAPI.On("LogWarn", "Error webhook post", "post", mock.Anything, "error", "error creating post").Times(1) + mockKvStore.EXPECT().Get("assigneeUserID_githubtoken", gomock.Any()).Return(nil).Times(1) }, }, { - name: "successful handling of pull request review event", - event: GetMockPullRequestReviewEvent("submitted", "approved"), + name: "no permission to the repo", + event: GetMockIssueCommentEventWithAssignees("issues", actionCreated, "mockBody", "mockUser", []string{"assigneeUser"}), setup: func() { - mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).DoAndReturn(func(key string, value interface{}) error { - if v, ok := value.(**Subscriptions); ok { - *v = GetMockSubscriptions() + mockKvStore.EXPECT().Get("assigneeUser_githubusername", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(*[]byte); ok { + *v = []byte("assigneeUserID") } return nil }).Times(1) - mockAPI.On("CreatePost", mock.Anything).Return(&model.Post{}, nil).Times(1) + mockKvStore.EXPECT().Get("assigneeUserID_githubtoken", gomock.Any()).Return(nil).Times(1) }, }, } - for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { tc.setup() - p.postPullRequestReviewEvent(tc.event) + p.handleCommentAssigneeNotification(tc.event) mockAPI.AssertExpectations(t) }) } } -func TestPostPullRequestReviewCommentEvent(t *testing.T) { +func TestHandlePullRequestNotification(t *testing.T) { mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) p := getPluginTest(mockAPI, mockKvStore) tests := []struct { name string - event *github.PullRequestReviewCommentEvent + event *github.PullRequestEvent setup func() }{ { - name: "no subscriptions found", - event: GetMockPullRequestReviewCommentEvent(), + name: "review requested by sender", + event: GetMockPullRequestEvent("review_requested", "mockRepo", MockValidLabel, false, "senderUser", "senderUser", ""), + setup: func() {}, + }, + { + name: "review requested with no repo permission", + event: GetMockPullRequestEvent("review_requested", "mockRepo", MockValidLabel, true, "senderUser", "requestedReviewer", ""), setup: func() { - mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).Return(nil).Times(1) + mockKvStore.EXPECT().Get("requestedReviewer_githubusername", gomock.Any()).Return(nil).Times(1) }, }, { - name: "error creating post", - event: GetMockPullRequestReviewCommentEvent(), + name: "pull request closed by author", + event: GetMockPullRequestEvent(actionClosed, "mockRepo", MockValidLabel, false, "authorUser", "authorUser", ""), + setup: func() {}, + }, + { + name: "pull request closed successfully", + event: GetMockPullRequestEvent(actionClosed, "mockRepo", MockValidLabel, false, "authorUser", "senderUser", ""), setup: func() { - mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).DoAndReturn(func(key string, value interface{}) error { - if v, ok := value.(**Subscriptions); ok { - *v = GetMockSubscriptions() + mockKvStore.EXPECT().Get("senderUser_githubusername", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(*[]byte); ok { + *v = []byte("authorUserID") + } + return nil + }).Times(1) + mockKvStore.EXPECT().Get("authorUserID_githubtoken", gomock.Any()).Return(nil).Times(1) + mockAPI.On("GetDirectChannel", "authorUserID", "mockBotID").Return(&model.Channel{Id: "mockChannelID"}, nil) + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, nil).Times(1) + mockAPI.On("LogWarn", "Failed to get github user info", "error", "Must connect user account to GitHub first.").Times(1) + }, + }, + { + name: "pull request reopened with no repo permission", + event: GetMockPullRequestEvent(actionReopened, "mockRepo", MockValidLabel, true, "authorUser", "senderUser", ""), + setup: func() { + mockKvStore.EXPECT().Get("senderUser_githubusername", gomock.Any()).Return(nil).Times(1) + }, + }, + { + name: "pull request assigned to self", + event: GetMockPullRequestEvent(actionAssigned, "mockRepo", MockValidLabel, false, "assigneeUser", "assigneeUser", "assigneeUser"), + setup: func() {}, + }, + { + name: "pull request assigned successfully", + event: GetMockPullRequestEvent(actionAssigned, "mockRepo", MockValidLabel, false, "senderUser", "assigneeUser", "assigneeUser"), + setup: func() { + mockKvStore.EXPECT().Get("assigneeUser_githubusername", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(*[]byte); ok { + *v = []byte("assigneeUserID") } return nil }).Times(1) - mockAPI.On("CreatePost", mock.Anything).Return(nil, &model.AppError{Message: "error creating post"}).Times(1) - mockAPI.On("LogWarn", "Error webhook post", "post", mock.Anything, "error", "error creating post").Times(1) + mockAPI.On("GetDirectChannel", "assigneeUserID", "mockBotID").Return(&model.Channel{Id: "mockChannelID"}, nil) + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, nil).Times(1) + mockKvStore.EXPECT().Get("assigneeUserID_githubtoken", gomock.Any()).Return(nil).Times(1) + mockAPI.On("LogWarn", "Failed to get github user info", "error", "Must connect user account to GitHub first.").Times(1) }, }, { - name: "successful handling of pull request review comment event", - event: GetMockPullRequestReviewCommentEvent(), + name: "review requested with valid user ID", + event: GetMockPullRequestEvent("review_requested", "mockRepo", MockValidLabel, false, "senderUser", "requestedReviewer", ""), setup: func() { - mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).DoAndReturn(func(key string, value interface{}) error { - if v, ok := value.(**Subscriptions); ok { - *v = GetMockSubscriptions() + mockKvStore.EXPECT().Get("requestedReviewer_githubusername", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(*[]byte); ok { + *v = []byte("requestedUserID") } return nil }).Times(1) - mockAPI.On("CreatePost", mock.Anything).Return(&model.Post{}, nil).Times(1) + mockAPI.On("GetDirectChannel", "requestedUserID", "mockBotID").Return(&model.Channel{Id: "mockChannelID"}, nil) + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, nil).Times(1) + mockKvStore.EXPECT().Get("requestedUserID_githubtoken", gomock.Any()).Return(nil).Times(1) + mockAPI.On("LogWarn", "Failed to get github user info", "error", "Must connect user account to GitHub first.").Times(1) + }, + }, + { + name: "unhandled event action", + event: GetMockPullRequestEvent( + "unsupported_action", "mockRepo", MockValidLabel, false, "senderUser", "", ""), + setup: func() { + mockAPI.On("LogDebug", "Unhandled event action", "action", "unsupported_action").Return(nil).Times(1) }, }, } @@ -486,89 +1973,81 @@ func TestPostPullRequestReviewCommentEvent(t *testing.T) { t.Run(tc.name, func(t *testing.T) { tc.setup() - p.postPullRequestReviewCommentEvent(tc.event) + p.handlePullRequestNotification(tc.event) mockAPI.AssertExpectations(t) }) } } -func TestHandleCommentMentionNotification(t *testing.T) { +func TestHandleIssueNotification(t *testing.T) { mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) p := getPluginTest(mockAPI, mockKvStore) tests := []struct { name string - event *github.IssueCommentEvent + event *github.IssuesEvent setup func() }{ { - name: "unsupported action", - event: GetMockIssueCommentEvent(actionEdited, "mockBody", "mockUser"), - setup: func() {}, - }, - { - name: "commenter is the same as mentioned user", - event: GetMockIssueCommentEvent(actionCreated, "mention @mockUser", "mockUser"), + name: "issue closed by author", + event: GetMockIssuesEvent(actionClosed, MockRepo, false, "authorUser", "authorUser", ""), setup: func() {}, }, { - name: "comment mentions issue author", - event: GetMockIssueCommentEvent(actionCreated, "mention @issueAuthor", "mockUser"), - setup: func() {}, + name: "issue closed successfully", + event: GetMockIssuesEvent(actionClosed, MockRepo, true, "authorUser", "senderUser", ""), + setup: func() { + mockKvStore.EXPECT().Get("authorUser_githubusername", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(*[]byte); ok { + *v = []byte("authorUserID") + } + return nil + }).Times(1) + mockKvStore.EXPECT().Get("authorUserID_githubtoken", gomock.Any()).Return(nil).Times(1) + }, }, { - name: "error getting channel details", - event: GetMockIssueCommentEvent(actionCreated, "mention @otherUser", "mockUser"), + name: "issue reopened with no repo permission", + event: GetMockIssuesEvent(actionReopened, MockRepo, true, "authorUser", "senderUser", ""), setup: func() { - mockKvStore.EXPECT().Get("otherUser_githubusername", gomock.Any()).Return(nil).Times(1) + mockKvStore.EXPECT().Get("authorUser_githubusername", gomock.Any()).Return(nil).Times(1) }, }, { - name: "error getting channel details", - event: GetMockIssueCommentEvent(actionCreated, "mention @otherUser", "mockUser"), + name: "issue assigned to self", + event: GetMockIssuesEvent(actionAssigned, MockRepo, false, "assigneeUser", "assigneeUser", "assigneeUser"), + setup: func() {}, + }, + { + name: "issue assigned successfully", + event: GetMockIssuesEvent(actionAssigned, MockRepo, false, "senderUser", "assigneeUser", "assigneeUser"), setup: func() { - mockKvStore.EXPECT().Get("otherUser_githubusername", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + mockKvStore.EXPECT().Get("assigneeUser_githubusername", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { if v, ok := value.(*[]byte); ok { - *v = []byte("otherUserID") + *v = []byte("assigneeUserID") } return nil }).Times(1) - mockKvStore.EXPECT().Get("otherUserID_githubtoken", gomock.Any()).Return(nil).Times(1) - mockAPI.On("GetDirectChannel", "otherUserID", "mockBotID").Return(nil, &model.AppError{Message: "error getting channel"}).Times(1) }, }, { - name: "error creating post", - event: GetMockIssueCommentEvent(actionCreated, "mention @otherUser", "mockUser"), + name: "issue assigned with no repo permission for assignee", + event: GetMockIssuesEvent(actionAssigned, MockRepo, true, "senderUser", "demoassigneeUser", "assigneeUser"), setup: func() { - mockKvStore.EXPECT().Get("otherUser_githubusername", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + mockKvStore.EXPECT().Get("assigneeUser_githubusername", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { if v, ok := value.(*[]byte); ok { - *v = []byte("otherUserID") + *v = []byte("assigneeUserID") } return nil }).Times(1) - mockKvStore.EXPECT().Get("otherUserID_githubtoken", gomock.Any()).Return(nil).Times(1) - mockAPI.On("GetDirectChannel", "otherUserID", "mockBotID").Return(&model.Channel{Id: "mockChannelID"}, nil).Times(1) - mockAPI.On("CreatePost", mock.Anything).Return(nil, &model.AppError{Message: "error creating post"}).Times(1) - mockAPI.On("LogWarn", "Error creating mention post", "error", "error creating post").Times(1) - mockAPI.On("LogWarn", "Failed to get github user info", "error", "Must connect user account to GitHub first.").Times(1) }, }, { - name: "successful mention notification", - event: GetMockIssueCommentEvent(actionCreated, "mention @otherUser", "mockUser"), + name: "unhandled event action", + event: GetMockIssuesEvent("unsupported_action", MockRepo, false, "senderUser", "", ""), setup: func() { - mockKvStore.EXPECT().Get("otherUser_githubusername", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { - if v, ok := value.(*[]byte); ok { - *v = []byte("otherUserID") - } - return nil - }).Times(1) - mockKvStore.EXPECT().Get("otherUserID_githubtoken", gomock.Any()).Return(nil).Times(1) - mockAPI.On("GetDirectChannel", "otherUserID", "mockBotID").Return(&model.Channel{Id: "mockChannelID"}, nil).Times(1) - mockAPI.On("CreatePost", mock.Anything).Return(&model.Post{}, nil).Times(1) - mockAPI.On("LogWarn", "Failed to get github user info", "error", "Must connect user account to GitHub first.") + mockAPI.On("LogDebug", "Unhandled event action", "action", "unsupported_action").Return(nil).Times(1) }, }, } @@ -576,96 +2055,71 @@ func TestHandleCommentMentionNotification(t *testing.T) { t.Run(tc.name, func(t *testing.T) { tc.setup() - p.handleCommentMentionNotification(tc.event) + p.handleIssueNotification(tc.event) mockAPI.AssertExpectations(t) }) } } -func TestHandleCommentAuthorNotification(t *testing.T) { +func TestHandlePullRequestReviewNotification(t *testing.T) { mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) p := getPluginTest(mockAPI, mockKvStore) tests := []struct { name string - event *github.IssueCommentEvent + event *github.PullRequestReviewEvent setup func() }{ { - name: "author is the commenter", - event: GetMockIssueCommentEvent(actionCreated, "mockBody", "issueAuthor"), + name: "review submitted by author", + event: GetMockPullRequestReviewEvent(actionSubmitted, "approved", MockRepo, false, "authorUser", "authorUser"), setup: func() {}, }, { - name: "unsupported action", - event: GetMockIssueCommentEvent(actionEdited, "mockBody", "mockUser"), + name: "review action not submitted", + event: GetMockPullRequestReviewEvent("dismissed", "approved", MockRepo, false, "authorUser", "reviewerUser"), setup: func() {}, }, { - name: "author not mapped to user ID", - event: GetMockIssueCommentEvent(actionCreated, "mockBody", "mockUser"), - setup: func() { - mockKvStore.EXPECT().Get("issueAuthor_githubusername", gomock.Any()).Return(nil).Times(1) - }, - }, - { - name: "author has no permission to repo", - event: GetMockIssueCommentEvent(actionCreated, "mockBody", "mockUser"), + name: "review with author not mapped to user ID", + event: GetMockPullRequestReviewEvent(actionSubmitted, "approved", MockRepo, false, "unknownAuthor", "reviewerUser"), setup: func() { - mockKvStore.EXPECT().Get("issueAuthor_githubusername", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { - if v, ok := value.(*[]byte); ok { - *v = []byte("authorUserID") - } - return nil - }).Times(1) + mockKvStore.EXPECT().Get("reviewerUser_githubusername", gomock.Any()).Return(nil).Times(1) }, }, { - name: "unhandled issue type", - event: GetMockIssueCommentEventWithURL(actionCreated, "mockBody", "mockUser", "https://mockurl.com/unhandledType/123"), + name: "private repo, no permission for author", + event: GetMockPullRequestReviewEvent(actionSubmitted, "approved", MockRepo, true, "authorUser", "reviewerUser"), setup: func() { - mockKvStore.EXPECT().Get("issueAuthor_githubusername", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + mockKvStore.EXPECT().Get("reviewerUser_githubusername", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { if v, ok := value.(*[]byte); ok { *v = []byte("authorUserID") } return nil }).Times(1) - mockAPI.On("LogDebug", "Unhandled issue type", "type", "unhandledType").Times(1) - }, - }, - { - name: "error creating post", - event: GetMockIssueCommentEventWithURL(actionCreated, "mockBody", "mockUser", "https://mockurl.com/issues/123"), - setup: func() { - mockKvStore.EXPECT().Get("issueAuthor_githubusername", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + mockKvStore.EXPECT().Get("authorUserID_githubtoken", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { if v, ok := value.(*[]byte); ok { *v = []byte("authorUserID") } return nil }).Times(1) - mockKvStore.EXPECT().Get("authorUserID-muted-users", gomock.Any()).Return(nil).Times(1) - mockKvStore.EXPECT().Get("authorUserID_githubtoken", gomock.Any()).Return(nil).Times(1) - mockAPI.On("GetDirectChannel", "authorUserID", "mockBotID").Return(&model.Channel{Id: "mockChannelID"}, nil).Times(1) - mockAPI.On("CreatePost", mock.Anything).Return(&model.Post{}, nil, &model.AppError{Message: "error creating post"}).Times(1) - mockAPI.On("LogWarn", "Failed to get github user info", "error", "Must connect user account to GitHub first.").Times(1) }, }, { - name: "successful notification", - event: GetMockIssueCommentEventWithURL(actionCreated, "mockBody", "mockUser", "https://mockurl.com/issues/123"), + name: "successful review notification", + event: GetMockPullRequestReviewEvent(actionSubmitted, "approved", MockRepo, false, "authorUser", "reviewerUser"), setup: func() { - mockKvStore.EXPECT().Get("issueAuthor_githubusername", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + mockKvStore.EXPECT().Get("reviewerUser_githubusername", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { if v, ok := value.(*[]byte); ok { *v = []byte("authorUserID") } return nil }).Times(1) - mockKvStore.EXPECT().Get("authorUserID-muted-users", gomock.Any()).Return(nil).Times(1) + mockAPI.On("GetDirectChannel", "authorUserID", "mockBotID").Return(nil, &model.AppError{Message: "error getting channel"}).Times(1) + mockAPI.On("LogWarn", "Couldn't get bot's DM channel", "userID", "authorUserID", "error", "error getting channel") mockKvStore.EXPECT().Get("authorUserID_githubtoken", gomock.Any()).Return(nil).Times(1) - mockAPI.On("LogWarn", "Failed to get github user info", "error", "Must connect user account to GitHub first.").Times(1) - mockAPI.On("GetDirectChannel", "authorUserID", "mockBotID").Return(&model.Channel{Id: "mockChannelID"}, nil).Times(1) - mockAPI.On("CreatePost", mock.Anything).Return(&model.Post{}, nil).Times(1) + mockAPI.On("LogWarn", "Failed to get github user info", "error", "Must connect user account to GitHub first.") }, }, } @@ -673,81 +2127,114 @@ func TestHandleCommentAuthorNotification(t *testing.T) { t.Run(tc.name, func(t *testing.T) { tc.setup() - p.handleCommentAuthorNotification(tc.event) + p.handlePullRequestReviewNotification(tc.event) mockAPI.AssertExpectations(t) }) } } -func TestHandleCommentAssigneeNotification(t *testing.T) { +func TestPostStarEvent(t *testing.T) { mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) p := getPluginTest(mockAPI, mockKvStore) tests := []struct { name string - event *github.IssueCommentEvent + event *github.StarEvent setup func() }{ { - name: "unsupported issue type", - event: GetMockIssueCommentEventWithAssignees("mockType", actionCreated, "mockBody", "mockUser", []string{"assigneeUser"}), + name: "no subscribed channels for repository", + event: GetMockStarEvent(MockRepo, MockOrg, false, MockSender), setup: func() { - mockAPI.On("LogDebug", "Unhandled issue type", "Type", "mockType") + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).Return(nil).Times(1) }, }, { - name: "assignee is the author", - event: GetMockIssueCommentEventWithAssignees("issues", actionCreated, "mockBody", "assigneeUser", []string{"assigneeUser"}), + name: "error creating post", + event: GetMockStarEvent(MockRepo, MockOrg, false, MockSender), setup: func() { - mockKvStore.EXPECT().Get("assigneeUser_githubusername", gomock.Any()).Return(nil).Times(1) + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(**Subscriptions); ok { + *v = GetMockSubscriptionWithLabel("mockrepo/mockorg", featureStars) + } + return nil + }).Times(1) + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(nil, &model.AppError{Message: "error creating post"}).Times(1) + mockAPI.On("LogWarn", "Error webhook post", "post", mock.AnythingOfType("*model.Post"), "error", "error creating post") }, }, { - name: "issue author is assignee", - event: GetMockIssueCommentEventWithAssignees("issues", actionCreated, "mockBody", "assigneeUser", []string{"issueAuthor"}), + name: "successful star event notification", + event: GetMockStarEvent(MockRepo, MockOrg, false, MockSender), setup: func() { - mockKvStore.EXPECT().Get("issueAuthor_githubusername", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { - if v, ok := value.(*[]byte); ok { - *v = []byte("issueAuthor") + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(**Subscriptions); ok { + *v = GetMockSubscriptionWithLabel("mockrepo/mockorg", featureStars) } return nil }).Times(1) + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, nil).Times(1) }, }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.setup() + + p.postStarEvent(tc.event) + + mockAPI.AssertExpectations(t) + }) + } +} + +func TestPostReleaseEvent(t *testing.T) { + mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) + p := getPluginTest(mockAPI, mockKvStore) + + tests := []struct { + name string + event *github.ReleaseEvent + setup func() + }{ { - name: "assignee is the sender", - event: GetMockIssueCommentEventWithAssignees("issues", actionCreated, "mockBody", "mockUser", []string{"mockUser"}), + name: "no subscribed channels for repository", + event: GetMockReleaseEvent(MockRepo, MockOrg, "created", MockSender), setup: func() { - mockKvStore.EXPECT().Get("mockUser_githubusername", gomock.Any()).Return(nil).Times(1) + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).Return(nil).Times(1) }, }, { - name: "comment mentions assignee (self-mention)", - event: GetMockIssueCommentEventWithAssignees("issues", actionCreated, "mention @assigneeUser", "mockUser", []string{"assigneeUser"}), + name: "unsupported action", + event: GetMockReleaseEvent(MockRepo, MockOrg, "edited", MockSender), + setup: func() {}, + }, + { + name: "error creating post", + event: GetMockReleaseEvent(MockRepo, MockOrg, "created", MockSender), setup: func() { - mockKvStore.EXPECT().Get("assigneeUser_githubusername", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { - if v, ok := value.(*[]byte); ok { - *v = []byte("assigneeUserID") + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(**Subscriptions); ok { + *v = GetMockSubscriptionWithLabel("mockrepo/mockorg", featureReleases) } return nil }).Times(1) - mockKvStore.EXPECT().Get("assigneeUserID_githubtoken", gomock.Any()).Return(nil).Times(1) - // mockAPI.On("LogDebug", "Commenter is muted, skipping notification") - // mockAPI.On("CreatePost", mock.Anything).Return(&model.Post{}, nil).Times(1) + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(nil, &model.AppError{Message: "error creating post"}).Times(1) + mockAPI.On("LogWarn", "Error webhook post", "Post", mock.AnythingOfType("*model.Post"), "Error", "error creating post") }, }, { - name: "no permission to the repo", - event: GetMockIssueCommentEventWithAssignees("issues", actionCreated, "mockBody", "mockUser", []string{"assigneeUser"}), + name: "successful release event notification", + event: GetMockReleaseEvent(MockRepo, MockOrg, "created", MockSender), setup: func() { - mockKvStore.EXPECT().Get("assigneeUser_githubusername", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { - if v, ok := value.(*[]byte); ok { - *v = []byte("assigneeUserID") + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(**Subscriptions); ok { + *v = GetMockSubscriptionWithLabel("mockrepo/mockorg", featureReleases) } return nil }).Times(1) - mockKvStore.EXPECT().Get("assigneeUserID_githubtoken", gomock.Any()).Return(nil).Times(1) + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, nil).Times(1) }, }, } @@ -755,105 +2242,121 @@ func TestHandleCommentAssigneeNotification(t *testing.T) { t.Run(tc.name, func(t *testing.T) { tc.setup() - p.handleCommentAssigneeNotification(tc.event) + p.postReleaseEvent(tc.event) mockAPI.AssertExpectations(t) }) } } -func TestHandlePullRequestNotification(t *testing.T) { +func TestPostDiscussionEvent(t *testing.T) { mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) p := getPluginTest(mockAPI, mockKvStore) tests := []struct { name string - event *github.PullRequestEvent + event *github.DiscussionEvent setup func() }{ { - name: "review requested by sender", - event: GetMockPullRequestEvent("review_requested", "mockRepo", false, "senderUser", "senderUser", ""), - setup: func() {}, - }, - { - name: "review requested with no repo permission", - event: GetMockPullRequestEvent("review_requested", "mockRepo", true, "senderUser", "requestedReviewer", ""), + name: "no subscribed channels for repository", + event: GetMockDiscussionEvent(MockRepo, MockOrg, MockSender), setup: func() { - mockKvStore.EXPECT().Get("requestedReviewer_githubusername", gomock.Any()).Return(nil).Times(1) + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).Return(nil).Times(1) }, }, { - name: "pull request closed by author", - event: GetMockPullRequestEvent(actionClosed, "mockRepo", false, "authorUser", "authorUser", ""), - setup: func() {}, - }, - { - name: "pull request closed successfully", - event: GetMockPullRequestEvent(actionClosed, "mockRepo", false, "authorUser", "senderUser", ""), + name: "error creating discussion post", + event: GetMockDiscussionEvent(MockRepo, MockOrg, MockSender), setup: func() { - mockKvStore.EXPECT().Get("senderUser_githubusername", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { - if v, ok := value.(*[]byte); ok { - *v = []byte("authorUserID") + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(**Subscriptions); ok { + *v = GetMockSubscriptionWithLabel("mockrepo/mockorg", featureDiscussions) } return nil }).Times(1) - mockKvStore.EXPECT().Get("authorUserID_githubtoken", gomock.Any()).Return(nil).Times(1) - mockAPI.On("GetDirectChannel", "authorUserID", "mockBotID").Return(&model.Channel{Id: "mockChannelID"}, nil) - mockAPI.On("CreatePost", mock.Anything).Return(&model.Post{}, nil).Times(1) - mockAPI.On("LogWarn", "Failed to get github user info", "error", "Must connect user account to GitHub first.").Times(1) + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(nil, &model.AppError{Message: "error creating post"}).Times(1) + mockAPI.On("LogWarn", "Error creating discussion notification post", "Post", mock.AnythingOfType("*model.Post"), "Error", "error creating post") }, }, { - name: "pull request reopened with no repo permission", - event: GetMockPullRequestEvent(actionReopened, "mockRepo", true, "authorUser", "senderUser", ""), + name: "successful discussion notification", + event: GetMockDiscussionEvent(MockRepo, MockOrg, MockSender), setup: func() { - mockKvStore.EXPECT().Get("senderUser_githubusername", gomock.Any()).Return(nil).Times(1) + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(**Subscriptions); ok { + *v = GetMockSubscriptionWithLabel("mockrepo/mockorg", featureDiscussions) + } + return nil + }).Times(1) + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, nil).Times(1) }, }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.setup() + + p.postDiscussionEvent(tc.event) + + mockAPI.AssertExpectations(t) + }) + } +} + +func TestPostDiscussionCommentEvent(t *testing.T) { + mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) + p := getPluginTest(mockAPI, mockKvStore) + + tests := []struct { + name string + event *github.DiscussionCommentEvent + setup func() + }{ { - name: "pull request assigned to self", - event: GetMockPullRequestEvent(actionAssigned, "mockRepo", false, "assigneeUser", "assigneeUser", "assigneeUser"), - setup: func() {}, + name: "no subscribed channels for repository", + event: GetMockDiscussionCommentEvent(MockRepo, MockOrg, "created", MockSender), + setup: func() { + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).Return(nil).Times(1) + }, }, { - name: "pull request assigned successfully", - event: GetMockPullRequestEvent(actionAssigned, "mockRepo", false, "senderUser", "assigneeUser", "assigneeUser"), + name: "unsupported action", + event: GetMockDiscussionCommentEvent(MockRepo, MockOrg, "edited", MockSender), setup: func() { - mockKvStore.EXPECT().Get("assigneeUser_githubusername", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { - if v, ok := value.(*[]byte); ok { - *v = []byte("assigneeUserID") + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(**Subscriptions); ok { + *v = GetMockSubscriptionWithLabel("mockrepo/mockorg", featureDiscussionComments) } return nil }).Times(1) - mockAPI.On("GetDirectChannel", "assigneeUserID", "mockBotID").Return(&model.Channel{Id: "mockChannelID"}, nil) - mockAPI.On("CreatePost", mock.Anything).Return(&model.Post{}, nil).Times(1) - mockKvStore.EXPECT().Get("assigneeUserID_githubtoken", gomock.Any()).Return(nil).Times(1) - mockAPI.On("LogWarn", "Failed to get github user info", "error", "Must connect user account to GitHub first.").Times(1) }, }, { - name: "review requested with valid user ID", - event: GetMockPullRequestEvent("review_requested", "mockRepo", false, "senderUser", "requestedReviewer", ""), + name: "error creating discussion comment post", + event: GetMockDiscussionCommentEvent(MockRepo, MockOrg, "created", MockSender), setup: func() { - mockKvStore.EXPECT().Get("requestedReviewer_githubusername", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { - if v, ok := value.(*[]byte); ok { - *v = []byte("requestedUserID") + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(**Subscriptions); ok { + *v = GetMockSubscriptionWithLabel("mockrepo/mockorg", featureDiscussionComments) } return nil }).Times(1) - mockAPI.On("GetDirectChannel", "requestedUserID", "mockBotID").Return(&model.Channel{Id: "mockChannelID"}, nil) - mockAPI.On("CreatePost", mock.Anything).Return(&model.Post{}, nil).Times(1) - mockKvStore.EXPECT().Get("requestedUserID_githubtoken", gomock.Any()).Return(nil).Times(1) - mockAPI.On("LogWarn", "Failed to get github user info", "error", "Must connect user account to GitHub first.").Times(1) + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(nil, &model.AppError{Message: "error creating post"}).Times(1) + mockAPI.On("LogWarn", "Error creating discussion comment post", "Post", mock.AnythingOfType("*model.Post"), "Error", "error creating post") }, }, { - name: "unhandled event action", - event: GetMockPullRequestEvent( - "unsupported_action", "mockRepo", false, "senderUser", "", ""), + name: "successful discussion comment notification", + event: GetMockDiscussionCommentEvent(MockRepo, MockOrg, "created", MockSender), setup: func() { - mockAPI.On("LogDebug", "Unhandled event action", "action", "unsupported_action").Return(nil).Times(1) + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(**Subscriptions); ok { + *v = GetMockSubscriptionWithLabel("mockrepo/mockorg", featureDiscussionComments) + } + return nil + }).Times(1) + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, nil).Times(1) }, }, } @@ -861,7 +2364,7 @@ func TestHandlePullRequestNotification(t *testing.T) { t.Run(tc.name, func(t *testing.T) { tc.setup() - p.handlePullRequestNotification(tc.event) + p.postDiscussionCommentEvent(tc.event) mockAPI.AssertExpectations(t) })