From 1bc391b8c4c6f61564bc0259ce131c5cc3762be7 Mon Sep 17 00:00:00 2001 From: Abhishek Sah Date: Thu, 5 Aug 2021 13:30:04 +0530 Subject: [PATCH] feat: send slack notifications to authorized workspaces (#37) * feat: setup api to send slack notification Co-authored-by: Praveen Yadav * feat: send notifications to users and joined public/private channels Co-authored-by: Praveen Yadav * test: fix failing tests Co-authored-by: Praveen Yadav * test: refactor and test slack notifier client Co-authored-by: Praveen Yadav * test: refactor and test slack notifier service Co-authored-by: Praveen Yadav * test: slack notifier handler Co-authored-by: Praveen Yadav * test: fetching access token for a workspace * docs: add swagger schema * feat: add request payload validation in notifications api handler * refactor: move validation into domain Co-authored-by: Abhishek * refactor: custom validation using oneof tag Co-authored-by: Abhishek * refactor: use custom error Co-authored-by: Abhishek Co-authored-by: Praveen Yadav --- api/handlers/notifier.go | 64 ++++++ api/handlers/notifier_test.go | 185 +++++++++++++++++ api/handlers/swagger.yaml | 47 +++++ api/router.go | 1 + docs/notifications.go | 26 +++ domain/codeexchange.go | 1 + domain/notifier.go | 29 +++ go.mod | 1 + go.sum | 4 + mocks/CodeExchangeService.go | 21 ++ mocks/SlackNotifierService.go | 36 ++++ pkg/codeexchange/model.go | 1 + pkg/codeexchange/repository.go | 25 ++- pkg/codeexchange/repository_mock.go | 39 +++- pkg/codeexchange/repository_test.go | 129 +++++++++++- pkg/codeexchange/service.go | 4 + pkg/codeexchange/service_test.go | 35 ++++ pkg/slacknotifier/error.go | 41 ++++ pkg/slacknotifier/http.go | 107 ++++++++++ pkg/slacknotifier/http_test.go | 263 ++++++++++++++++++++++++ pkg/slacknotifier/model.go | 22 ++ pkg/slacknotifier/service.go | 40 ++++ pkg/slacknotifier/service_test.go | 93 +++++++++ pkg/slacknotifier/slackcaller_mock.go | 177 ++++++++++++++++ pkg/slacknotifier/slacknotifier_mock.go | 24 +++ service/container.go | 9 + 26 files changed, 1407 insertions(+), 17 deletions(-) create mode 100644 api/handlers/notifier.go create mode 100644 api/handlers/notifier_test.go create mode 100644 docs/notifications.go create mode 100644 domain/notifier.go create mode 100644 mocks/SlackNotifierService.go create mode 100644 pkg/slacknotifier/error.go create mode 100644 pkg/slacknotifier/http.go create mode 100644 pkg/slacknotifier/http_test.go create mode 100644 pkg/slacknotifier/model.go create mode 100644 pkg/slacknotifier/service.go create mode 100644 pkg/slacknotifier/service_test.go create mode 100644 pkg/slacknotifier/slackcaller_mock.go create mode 100644 pkg/slacknotifier/slacknotifier_mock.go diff --git a/api/handlers/notifier.go b/api/handlers/notifier.go new file mode 100644 index 00000000..5f09b94b --- /dev/null +++ b/api/handlers/notifier.go @@ -0,0 +1,64 @@ +package handlers + +import ( + "encoding/json" + "github.com/odpf/siren/domain" + "github.com/odpf/siren/pkg/slacknotifier" + "github.com/pkg/errors" + "go.uber.org/zap" + "gopkg.in/go-playground/validator.v9" + "net/http" +) + +// Notify handler +func Notify(notifierServices domain.NotifierServices, logger *zap.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + provider := r.URL.Query().Get("provider") + switch provider { + case "slack": + var payload domain.SlackMessage + err := json.NewDecoder(r.Body).Decode(&payload) + if err != nil { + badRequest(w, err, logger) + return + } + + err = payload.Validate() + if err != nil { + var e *validator.InvalidValidationError + if errors.As(err, &e) { + logger.Error("invalid validation error") + internalServerError(w, err, logger) + return + } + badRequest(w, err, logger) + return + } + result, err := notifierServices.Slack.Notify(&payload) + if err != nil { + if isBadRequest(err) { + badRequest(w, err, logger) + return + } + internalServerError(w, err, logger) + return + } + returnJSON(w, result) + return + case "": + badRequest(w, errors.New("provider not given in query params"), logger) + return + default: + badRequest(w, errors.New("unrecognized provider"), logger) + return + } + } +} + +func isBadRequest(err error) bool { + + var noChannelFoundError *slacknotifier.NoChannelFoundErr + var userLookupByEmailErr *slacknotifier.UserLookupByEmailErr + + return errors.As(err, &noChannelFoundError) || errors.As(err, &userLookupByEmailErr) +} diff --git a/api/handlers/notifier_test.go b/api/handlers/notifier_test.go new file mode 100644 index 00000000..ddc6466f --- /dev/null +++ b/api/handlers/notifier_test.go @@ -0,0 +1,185 @@ +package handlers_test + +import ( + "bytes" + "encoding/json" + "errors" + "github.com/odpf/siren/api/handlers" + "github.com/odpf/siren/domain" + "github.com/odpf/siren/mocks" + "github.com/odpf/siren/pkg/slacknotifier" + "github.com/stretchr/testify/assert" + "net/http" + "net/http/httptest" + "testing" +) + +func TestNotifier_Notify(t *testing.T) { + t.Run("should return 200 OK on success", func(t *testing.T) { + mockedSlackNotifierService := &mocks.SlackNotifierService{} + notifierServices := domain.NotifierServices{Slack: mockedSlackNotifierService} + dummyMessage := domain.SlackMessage{ReceiverName: "foo", + ReceiverType: "user", + Message: "some text", + Entity: "odpf"} + + payload := []byte(`{"receiver_name": "foo","receiver_type": "user","entity": "odpf","message": "some text"}`) + expectedResponse := domain.SlackMessageSendResponse{ + OK: true, + } + mockedSlackNotifierService.On("Notify", &dummyMessage).Return(&expectedResponse, nil).Once() + r, err := http.NewRequest(http.MethodPost, "/notifications?provider=slack", bytes.NewBuffer(payload)) + if err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + handler := handlers.Notify(notifierServices, getPanicLogger()) + expectedStatusCode := http.StatusOK + response, _ := json.Marshal(expectedResponse) + expectedStringBody := string(response) + "\n" + + handler.ServeHTTP(w, r) + + assert.Equal(t, expectedStatusCode, w.Code) + assert.Equal(t, expectedStringBody, w.Body.String()) + }) + + t.Run("should return 500 Internal server error on failure", func(t *testing.T) { + mockedSlackNotifierService := &mocks.SlackNotifierService{} + notifierServices := domain.NotifierServices{Slack: mockedSlackNotifierService} + dummyMessage := domain.SlackMessage{ReceiverName: "foo", + ReceiverType: "user", + Message: "some text", + Entity: "odpf"} + + payload := []byte(`{"receiver_name": "foo","receiver_type": "user","entity": "odpf","message": "some text"}`) + mockedSlackNotifierService.On("Notify", &dummyMessage).Return(nil, errors.New("random error")).Once() + r, err := http.NewRequest(http.MethodPost, "/notifications?provider=slack", bytes.NewBuffer(payload)) + if err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + handler := handlers.Notify(notifierServices, getPanicLogger()) + expectedStatusCode := http.StatusInternalServerError + expectedStringBody := `{"code":500,"message":"Internal server error","data":null}` + handler.ServeHTTP(w, r) + assert.Equal(t, expectedStatusCode, w.Code) + assert.Equal(t, expectedStringBody, w.Body.String()) + }) + + t.Run("should return 400 Bad request if app not part of channel", func(t *testing.T) { + mockedSlackNotifierService := &mocks.SlackNotifierService{} + notifierServices := domain.NotifierServices{Slack: mockedSlackNotifierService} + dummyMessage := domain.SlackMessage{ReceiverName: "test", + ReceiverType: "channel", + Message: "some text", + Entity: "odpf"} + expectedError := &slacknotifier.NoChannelFoundErr{ + Err: errors.New("app is not part of test"), + } + payload := []byte(`{"receiver_name": "test","receiver_type": "channel","entity": "odpf","message": "some text"}`) + mockedSlackNotifierService.On("Notify", &dummyMessage).Return(nil, expectedError).Once() + r, err := http.NewRequest(http.MethodPost, "/notifications?provider=slack", bytes.NewBuffer(payload)) + if err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + handler := handlers.Notify(notifierServices, getPanicLogger()) + expectedStatusCode := http.StatusBadRequest + expectedStringBody := `{"code":400,"message":"app is not part of test","data":null}` + handler.ServeHTTP(w, r) + assert.Equal(t, expectedStatusCode, w.Code) + assert.Equal(t, expectedStringBody, w.Body.String()) + }) + + t.Run("should return 400 Bad request if user not found", func(t *testing.T) { + mockedSlackNotifierService := &mocks.SlackNotifierService{} + notifierServices := domain.NotifierServices{Slack: mockedSlackNotifierService} + dummyMessage := domain.SlackMessage{ReceiverName: "foo", + ReceiverType: "user", + Message: "some text", + Entity: "odpf"} + expectedError := &slacknotifier.UserLookupByEmailErr{ + Err: errors.New("failed to get id for foo"), + } + payload := []byte(`{"receiver_name": "foo","receiver_type": "user","entity": "odpf","message": "some text"}`) + mockedSlackNotifierService.On("Notify", &dummyMessage).Return(nil, expectedError).Once() + r, err := http.NewRequest(http.MethodPost, "/notifications?provider=slack", bytes.NewBuffer(payload)) + if err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + handler := handlers.Notify(notifierServices, getPanicLogger()) + expectedStatusCode := http.StatusBadRequest + expectedStringBody := `{"code":400,"message":"failed to get id for foo","data":null}` + handler.ServeHTTP(w, r) + assert.Equal(t, expectedStatusCode, w.Code) + assert.Equal(t, expectedStringBody, w.Body.String()) + }) + + t.Run("should return 400 Bad request if no provider specified", func(t *testing.T) { + mockedSlackNotifierService := &mocks.SlackNotifierService{} + notifierServices := domain.NotifierServices{Slack: mockedSlackNotifierService} + payload := []byte(`{"receiver_name": "foo","receiver_type": "user","entity": "odpf","message": "some text"}`) + r, err := http.NewRequest(http.MethodPost, "/notifications", bytes.NewBuffer(payload)) + if err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + handler := handlers.Notify(notifierServices, getPanicLogger()) + expectedStatusCode := http.StatusBadRequest + expectedStringBody := `{"code":400,"message":"provider not given in query params","data":null}` + handler.ServeHTTP(w, r) + assert.Equal(t, expectedStatusCode, w.Code) + assert.Equal(t, expectedStringBody, w.Body.String()) + }) + + t.Run("should return 400 Bad request if unknown provider specified", func(t *testing.T) { + mockedSlackNotifierService := &mocks.SlackNotifierService{} + notifierServices := domain.NotifierServices{Slack: mockedSlackNotifierService} + payload := []byte(`{"receiver_name": "foo","receiver_type": "user","entity": "odpf","message": "some text"}`) + r, err := http.NewRequest(http.MethodPost, "/notifications?provider=email", bytes.NewBuffer(payload)) + if err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + handler := handlers.Notify(notifierServices, getPanicLogger()) + expectedStatusCode := http.StatusBadRequest + expectedStringBody := `{"code":400,"message":"unrecognized provider","data":null}` + handler.ServeHTTP(w, r) + assert.Equal(t, expectedStatusCode, w.Code) + assert.Equal(t, expectedStringBody, w.Body.String()) + }) + + t.Run("should return 400 Bad request for invalid payload", func(t *testing.T) { + mockedSlackNotifierService := &mocks.SlackNotifierService{} + notifierServices := domain.NotifierServices{Slack: mockedSlackNotifierService} + payload := []byte(`abcd`) + r, err := http.NewRequest(http.MethodPost, "/notifications?provider=slack", bytes.NewBuffer(payload)) + if err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + handler := handlers.Notify(notifierServices, getPanicLogger()) + expectedStatusCode := http.StatusBadRequest + expectedStringBody := `{"code":400,"message":"invalid character 'a' looking for beginning of value","data":null}` + handler.ServeHTTP(w, r) + assert.Equal(t, expectedStatusCode, w.Code) + assert.Equal(t, expectedStringBody, w.Body.String()) + }) + + t.Run("should return 400 Bad request if payload validation", func(t *testing.T) { + mockedSlackNotifierService := &mocks.SlackNotifierService{} + notifierServices := domain.NotifierServices{Slack: mockedSlackNotifierService} + payload := []byte(`{"receiver_name": "","receiver_type": "","entity": "","message": ""}`) + r, err := http.NewRequest(http.MethodPost, "/notifications?provider=slack", bytes.NewBuffer(payload)) + if err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + handler := handlers.Notify(notifierServices, getPanicLogger()) + expectedStatusCode := http.StatusBadRequest + handler.ServeHTTP(w, r) + assert.Equal(t, expectedStatusCode, w.Code) + }) +} diff --git a/api/handlers/swagger.yaml b/api/handlers/swagger.yaml index 9fc6f1e4..21d5ca38 100644 --- a/api/handlers/swagger.yaml +++ b/api/handlers/swagger.yaml @@ -181,6 +181,29 @@ definitions: x-go-name: Webhook type: object x-go-package: github.com/odpf/siren/domain + SlackMessage: + properties: + entity: + type: string + x-go-name: Entity + message: + type: string + x-go-name: Message + receiver_name: + type: string + x-go-name: ReceiverName + receiver_type: + type: string + x-go-name: ReceiverType + type: object + x-go-package: github.com/odpf/siren/domain + SlackMessageSendResponse: + properties: + ok: + type: boolean + x-go-name: OK + type: object + x-go-package: github.com/odpf/siren/domain Template: properties: body: @@ -272,6 +295,26 @@ paths: type: array tags: - alertHistory + /notifications: + post: + description: |- + POST Notifications API + This API sends notifications to configured channel + operationId: postNotificationsRequest + parameters: + - in: query + name: provider + type: string + x-go-name: Provider + - in: body + name: Body + schema: + $ref: '#/definitions/SlackMessage' + responses: + "200": + $ref: '#/responses/postResponse' + tags: + - notifications /oauth/slack/token: post: description: |- @@ -489,6 +532,10 @@ responses: type: array pingResponse: description: Response body for Ping. + postNotificationsResponse: + description: POST notifications response + schema: + $ref: '#/definitions/SlackMessageSendResponse' postResponse: description: POST codeExchange response schema: diff --git a/api/router.go b/api/router.go index 68848c46..87339485 100644 --- a/api/router.go +++ b/api/router.go @@ -50,6 +50,7 @@ func New(container *service.Container, nr *newrelic.Application, logger *zap.Log r.Methods("GET").Path("/history").Handler(handlers.GetAlertHistory(container.AlertHistoryService, logger)) r.Methods("POST").Path("/oauth/slack/token").Handler(handlers.ExchangeCode(container.CodeExchangeService, logger)) + r.Methods("POST").Path("/notifications").Handler(handlers.Notify(container.NotifierServices, logger)) // Handle middlewares for NotFoundHandler and MethodNotAllowedHandler since Mux doesn't apply middlewares to them. Ref: https://github.com/gorilla/mux/issues/416 _, r.NotFoundHandler = newrelic.WrapHandle(nr, "NotFoundHandler", applyMiddlewaresToHandler(zapMiddlewares, http.NotFoundHandler())) diff --git a/docs/notifications.go b/docs/notifications.go new file mode 100644 index 00000000..504800ea --- /dev/null +++ b/docs/notifications.go @@ -0,0 +1,26 @@ +package docs + +import "github.com/odpf/siren/domain" + +//------------------------- +//------------------------- +// swagger:route POST /notifications notifications postNotificationsRequest +// POST Notifications API +// This API sends notifications to configured channel +// responses: +// 200: postResponse + +// swagger:parameters postNotificationsRequest +type postNotificationsRequest struct { + // in:query + Provider string `json:"provider"` + // in:body + Body domain.SlackMessage +} + +// POST notifications response +// swagger:response postNotificationsResponse +type postNotificationsResponse struct { + // in:body + Body domain.SlackMessageSendResponse +} diff --git a/domain/codeexchange.go b/domain/codeexchange.go index 4b1a132c..b013ae63 100644 --- a/domain/codeexchange.go +++ b/domain/codeexchange.go @@ -11,5 +11,6 @@ type OAuthExchangeResponse struct { type CodeExchangeService interface { Exchange(payload OAuthPayload) (*OAuthExchangeResponse, error) + GetToken(string) (string, error) Migrate() error } diff --git a/domain/notifier.go b/domain/notifier.go new file mode 100644 index 00000000..3e8c6b13 --- /dev/null +++ b/domain/notifier.go @@ -0,0 +1,29 @@ +package domain + +import ( + "gopkg.in/go-playground/validator.v9" +) + +type NotifierServices struct { + Slack SlackNotifierService +} + +type SlackMessageSendResponse struct { + OK bool `json:"ok"` +} + +type SlackNotifierService interface { + Notify(*SlackMessage) (*SlackMessageSendResponse, error) +} + +type SlackMessage struct { + ReceiverName string `json:"receiver_name" validate:"required"` + ReceiverType string `json:"receiver_type" validate:"required,oneof=user channel"` + Entity string `json:"entity" validate:"required"` + Message string `json:"message" validate:"required"` +} + +func (sm *SlackMessage) Validate() error { + v := validator.New() + return v.Struct(sm) +} diff --git a/go.mod b/go.mod index 6e8d9d96..b8f87da7 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( github.com/prometheus/alertmanager v0.21.1-0.20200911160112-1fdff6b3f939 github.com/prometheus/prometheus v1.8.2-0.20201014093524-73e2ce1bd643 github.com/purini-to/zapmw v1.1.0 + github.com/slack-go/slack v0.9.3 github.com/spf13/afero v1.4.1 // indirect github.com/spf13/cast v1.3.1 // indirect github.com/spf13/cobra v1.1.3 diff --git a/go.sum b/go.sum index 5cf0b441..58bd8c34 100644 --- a/go.sum +++ b/go.sum @@ -470,6 +470,7 @@ github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/gobuffalo/attrs v0.0.0-20190224210810-a9411de4debd/go.mod h1:4duuawTqi2wkkpB4ePgWMaai6/Kc6WEz83bhFwpHzj0= github.com/gobuffalo/depgen v0.0.0-20190329151759-d478694a28d3/go.mod h1:3STtPUQYuzV0gBVOY3vy6CfMm/ljR4pABfrTeHNLHUY= github.com/gobuffalo/depgen v0.1.0/go.mod h1:+ifsuy7fhi15RWncXQQKjWS9JPkdah5sZvtHc2RXGlg= @@ -631,6 +632,7 @@ github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB7 github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grafana/loki v1.6.2-0.20201117140412-14a5fda15b07/go.mod h1:Rcg4a7v6TsdiC8T127YLYj+DOYQyeiiSMri3X+xCIUo= github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= @@ -1195,6 +1197,8 @@ github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6Mwd github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo= github.com/sirupsen/logrus v1.6.0 h1:UBcNElsrwanuuMsnGSlYmtmgbb23qDR5dG+6X6Oo89I= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= +github.com/slack-go/slack v0.9.3 h1:H1UwldF1zWQakjaSymbHMgG3Pg1BiClez/a7JRLdxKc= +github.com/slack-go/slack v0.9.3/go.mod h1:wWL//kk0ho+FcQXcBTmEafUI5dz4qz5f4mMk8oIkioQ= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/assertions v1.0.1 h1:voD4ITNjPL5jjBfgR/r8fPIIBrliWrWHeiJApdr3r4w= github.com/smartystreets/assertions v1.0.1/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM= diff --git a/mocks/CodeExchangeService.go b/mocks/CodeExchangeService.go index 03abbf91..f25e780e 100644 --- a/mocks/CodeExchangeService.go +++ b/mocks/CodeExchangeService.go @@ -35,6 +35,27 @@ func (_m *CodeExchangeService) Exchange(payload domain.OAuthPayload) (*domain.OA return r0, r1 } +// GetToken provides a mock function with given fields: _a0 +func (_m *CodeExchangeService) GetToken(_a0 string) (string, error) { + ret := _m.Called(_a0) + + var r0 string + if rf, ok := ret.Get(0).(func(string) string); ok { + r0 = rf(_a0) + } else { + r0 = ret.Get(0).(string) + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // Migrate provides a mock function with given fields: func (_m *CodeExchangeService) Migrate() error { ret := _m.Called() diff --git a/mocks/SlackNotifierService.go b/mocks/SlackNotifierService.go new file mode 100644 index 00000000..76fb86d3 --- /dev/null +++ b/mocks/SlackNotifierService.go @@ -0,0 +1,36 @@ +// Code generated by mockery 2.9.0. DO NOT EDIT. + +package mocks + +import ( + domain "github.com/odpf/siren/domain" + mock "github.com/stretchr/testify/mock" +) + +// SlackNotifierService is an autogenerated mock type for the SlackNotifierService type +type SlackNotifierService struct { + mock.Mock +} + +// Notify provides a mock function with given fields: _a0 +func (_m *SlackNotifierService) Notify(_a0 *domain.SlackMessage) (*domain.SlackMessageSendResponse, error) { + ret := _m.Called(_a0) + + var r0 *domain.SlackMessageSendResponse + if rf, ok := ret.Get(0).(func(*domain.SlackMessage) *domain.SlackMessageSendResponse); ok { + r0 = rf(_a0) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*domain.SlackMessageSendResponse) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*domain.SlackMessage) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/pkg/codeexchange/model.go b/pkg/codeexchange/model.go index 90f08b6e..3725347b 100644 --- a/pkg/codeexchange/model.go +++ b/pkg/codeexchange/model.go @@ -14,5 +14,6 @@ type AccessToken struct { type ExchangeRepository interface { Upsert(*AccessToken) error + Get(string) (string, error) Migrate() error } diff --git a/pkg/codeexchange/repository.go b/pkg/codeexchange/repository.go index 486e164f..7692c3f6 100644 --- a/pkg/codeexchange/repository.go +++ b/pkg/codeexchange/repository.go @@ -18,6 +18,7 @@ type Repository struct { } var cryptopastaEncryptor = cryptopasta.Encrypt + func encryptToken(accessToken string, encryptionKey *[32]byte) (string, error) { cipher, err := cryptopastaEncryptor([]byte(accessToken), encryptionKey) if err != nil { @@ -27,12 +28,16 @@ func encryptToken(accessToken string, encryptionKey *[32]byte) (string, error) { } var cryptopastaDecryptor = cryptopasta.Decrypt + func decryptToken(accessToken string, encryptionKey *[32]byte) (string, error) { - encrypted, err := base64.StdEncoding.DecodeString( accessToken) + encrypted, err := base64.StdEncoding.DecodeString(accessToken) + if err != nil { + return "", err + } + decryptedToken, err := cryptopastaDecryptor(encrypted, encryptionKey) if err != nil { return "", err } - decryptedToken, _ := cryptopastaDecryptor([]byte(encrypted), encryptionKey) return string(decryptedToken), nil } @@ -73,6 +78,22 @@ func (r Repository) Upsert(accessToken *AccessToken) error { return nil } +func (r Repository) Get(workspace string) (string, error) { + var accessToken AccessToken + result := r.db.Where(fmt.Sprintf("workspace = '%s'", workspace)).Find(&accessToken) + if result.Error != nil { + return "", errors.Wrap(result.Error, "search query failed") + } + if result.RowsAffected == 0 { + return "", errors.New(fmt.Sprintf("workspace not found: %s", workspace)) + } + decryptedAccessToken, err := decryptToken(accessToken.AccessToken, r.encryptionKey) + if err != nil { + return "", errors.Wrap(err, "failed to decrypt token") + } + return decryptedAccessToken, nil +} + func (r Repository) Migrate() error { err := r.db.AutoMigrate(&AccessToken{}) if err != nil { diff --git a/pkg/codeexchange/repository_mock.go b/pkg/codeexchange/repository_mock.go index 3f729813..a299fd6f 100644 --- a/pkg/codeexchange/repository_mock.go +++ b/pkg/codeexchange/repository_mock.go @@ -2,25 +2,34 @@ package codeexchange -import mock "github.com/stretchr/testify/mock" +import ( + mock "github.com/stretchr/testify/mock" +) -// MockExchangeRepository is an autogenerated mock type for the ExchangeRepository type +// MockExchangeRepository is an autogenerated mock type for the MockExchangeRepository type type MockExchangeRepository struct { mock.Mock } -// Upsert provides a mock function with given fields: _a0 -func (_m *MockExchangeRepository) Upsert(_a0 *AccessToken) error { +// Get provides a mock function with given fields: _a0 +func (_m *MockExchangeRepository) Get(_a0 string) (string, error) { ret := _m.Called(_a0) - var r0 error - if rf, ok := ret.Get(0).(func(*AccessToken) error); ok { + var r0 string + if rf, ok := ret.Get(0).(func(string) string); ok { r0 = rf(_a0) } else { - r0 = ret.Error(0) + r0 = ret.Get(0).(string) } - return r0 + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 } // Migrate provides a mock function with given fields: @@ -36,3 +45,17 @@ func (_m *MockExchangeRepository) Migrate() error { return r0 } + +// Upsert provides a mock function with given fields: _a0 +func (_m *MockExchangeRepository) Upsert(_a0 *AccessToken) error { + ret := _m.Called(_a0) + + var r0 error + if rf, ok := ret.Get(0).(func(*AccessToken) error); ok { + r0 = rf(_a0) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/pkg/codeexchange/repository_test.go b/pkg/codeexchange/repository_test.go index d46bd709..b7537200 100644 --- a/pkg/codeexchange/repository_test.go +++ b/pkg/codeexchange/repository_test.go @@ -35,8 +35,9 @@ func TestRepository(t *testing.T) { suite.Run(t, new(RepositoryTestSuite)) } +const encryptionKey = "ASBzXLpOI0GOorN41dKF47gcFnaILuVh" + func (s *RepositoryTestSuite) SetupTest() { - encryptionKey := "ASBzXLpOI0GOorN41dKF47gcFnaILuVh" db, mock, _ := mocks.NewStore() s.sqldb, _ = db.DB() s.dbmock = mock @@ -48,7 +49,7 @@ func (s *RepositoryTestSuite) TearDownTest() { s.sqldb.Close() } -func (s *RepositoryTestSuite) TestExchange() { +func (s *RepositoryTestSuite) TestRepository_Upsert() { s.Run("should insert access token if workspace does not exist", func() { var oldCryptopastaEncryptor = cryptopasta.Encrypt defer func() { @@ -218,12 +219,126 @@ func (s *RepositoryTestSuite) TestExchange() { }) } +func (s *RepositoryTestSuite) TestRepository_Get() { + s.Run("should get decrypted and decoded access token", func() { + var oldCryptopastaDecryptor = cryptopastaDecryptor + timeNow := time.Now() + defer func() { + cryptopastaEncryptor = oldCryptopastaDecryptor + }() + cryptopastaDecryptor = func(_ []byte, _ *[32]byte) ([]byte, error) { + return []byte("test-token"), nil + } + + selectQuery := regexp.QuoteMeta(`SELECT * FROM "access_tokens" WHERE workspace = 'odpf'`) + + accessToken := &AccessToken{ + ID: 10, + CreatedAt: timeNow, + UpdatedAt: timeNow, + AccessToken: base64.StdEncoding.EncodeToString([]byte("test-token")), + Workspace: "odpf", + } + + expectedRows := sqlmock.NewRows([]string{"id", "created_at", "updated_at", "access_token", "workspace"}). + AddRow(accessToken.ID, accessToken.CreatedAt, accessToken.UpdatedAt, + accessToken.AccessToken, accessToken.Workspace) + + s.dbmock.ExpectQuery(selectQuery).WillReturnRows(expectedRows) + + token, err := s.repository.Get("odpf") + s.Equal("test-token", token) + s.Nil(err) + }) + + s.Run("should return error if workspace not found", func() { + + selectQuery := regexp.QuoteMeta(`SELECT * FROM "access_tokens" WHERE workspace = 'odpf'`) + + s.dbmock.ExpectQuery(selectQuery).WillReturnRows(sqlmock.NewRows(nil)) + + token, err := s.repository.Get("odpf") + s.Equal("", token) + s.EqualError(err, "workspace not found: odpf") + }) + + s.Run("should return error if query fails", func() { + selectQuery := regexp.QuoteMeta(`SELECT * FROM "access_tokens" WHERE workspace = 'odpf'`) + s.dbmock.ExpectQuery(selectQuery).WillReturnError(errors.New("random error")) + + token, err := s.repository.Get("odpf") + s.Equal("", token) + s.EqualError(err, "search query failed: random error") + }) + + s.Run("should return error if decryption fails", func() { + var oldCryptopastaDecryptor = cryptopastaDecryptor + timeNow := time.Now() + defer func() { + cryptopastaEncryptor = oldCryptopastaDecryptor + }() + cryptopastaDecryptor = func(_ []byte, _ *[32]byte) ([]byte, error) { + return []byte("test-token"), errors.New("random error") + } + + selectQuery := regexp.QuoteMeta(`SELECT * FROM "access_tokens" WHERE workspace = 'odpf'`) + + accessToken := &AccessToken{ + ID: 10, + CreatedAt: timeNow, + UpdatedAt: timeNow, + AccessToken: base64.StdEncoding.EncodeToString([]byte("test-token")), + Workspace: "odpf", + } + + expectedRows := sqlmock.NewRows([]string{"id", "created_at", "updated_at", "access_token", "workspace"}). + AddRow(accessToken.ID, accessToken.CreatedAt, accessToken.UpdatedAt, + accessToken.AccessToken, accessToken.Workspace) + + s.dbmock.ExpectQuery(selectQuery).WillReturnRows(expectedRows) + + token, err := s.repository.Get("odpf") + s.Equal("", token) + s.EqualError(err, "failed to decrypt token: random error") + }) + + s.Run("should return error if decoding fails", func() { + var oldCryptopastaDecryptor = cryptopastaDecryptor + timeNow := time.Now() + defer func() { + cryptopastaEncryptor = oldCryptopastaDecryptor + }() + cryptopastaDecryptor = func(_ []byte, _ *[32]byte) ([]byte, error) { + return []byte("test-token"), errors.New("random error") + } + + selectQuery := regexp.QuoteMeta(`SELECT * FROM "access_tokens" WHERE workspace = 'odpf'`) + + accessToken := &AccessToken{ + ID: 10, + CreatedAt: timeNow, + UpdatedAt: timeNow, + AccessToken: "test-token", + Workspace: "odpf", + } + + expectedRows := sqlmock.NewRows([]string{"id", "created_at", "updated_at", "access_token", "workspace"}). + AddRow(accessToken.ID, accessToken.CreatedAt, accessToken.UpdatedAt, + accessToken.AccessToken, accessToken.Workspace) + + s.dbmock.ExpectQuery(selectQuery).WillReturnRows(expectedRows) + + token, err := s.repository.Get("odpf") + s.Equal("", token) + s.EqualError(err, "failed to decrypt token: illegal base64 data at input byte 4") + }) +} + func (s *RepositoryTestSuite) TestNewRepository() { - s.Run("should through error if encryption key is less then 32 char", func(){ - encryptionKey := "ASBzXLpOI0GOorN41dKF47gcFnaI" - repo, err := NewRepository(nil, encryptionKey) + s.Run("should through error if encryption key is less then 32 char", func() { + key := "ASBzXLpOI0GOorN41dKF47gcFnaI" + repo, err := NewRepository(nil, key) s.Nil(repo) s.EqualError(err, "random hash should be 32 chars in length") }) - -} \ No newline at end of file +} diff --git a/pkg/codeexchange/service.go b/pkg/codeexchange/service.go index 53f960ce..01a13843 100644 --- a/pkg/codeexchange/service.go +++ b/pkg/codeexchange/service.go @@ -37,6 +37,10 @@ func NewService(db *gorm.DB, httpClient Doer, slackAppConfig domain.SlackApp, en }, nil } +func (service Service) GetToken(workspace string) (string, error) { + return service.repository.Get(workspace) +} + func (service Service) Exchange(payload domain.OAuthPayload) (*domain.OAuthExchangeResponse, error) { response, err := service.exchanger.Exchange(payload.Code, service.clientID, service.clientSecret) if err != nil { diff --git a/pkg/codeexchange/service_test.go b/pkg/codeexchange/service_test.go index 59ab60ab..bd3d9ac3 100644 --- a/pkg/codeexchange/service_test.go +++ b/pkg/codeexchange/service_test.go @@ -172,6 +172,41 @@ func (s *ServiceTestSuite) TestService_Exchange() { }) } +func (s *ServiceTestSuite) TestService_GetToken() { + s.Run("should call repository Get method", func() { + clientID, clientSecret := "test-client-id", "test-client-secret" + dummyService := Service{ + repository: s.repositoryMock, + exchanger: s.exchangerMock, + clientID: clientID, + clientSecret: clientSecret, + } + s.repositoryMock.On("Get", "odpf").Return("test-token", nil).Once() + + res, err := dummyService.GetToken("odpf") + + s.Nil(err) + s.Equal("test-token", res) + }) + + s.Run("should return error for repository Get method", func() { + clientID, clientSecret := "test-client-id", "test-client-secret" + dummyService := Service{ + repository: s.repositoryMock, + exchanger: s.exchangerMock, + clientID: clientID, + clientSecret: clientSecret, + } + s.repositoryMock.On("Get", "odpf"). + Return("", errors.New("random errors")).Once() + + res, err := dummyService.GetToken("odpf") + + s.Equal("", res) + s.EqualError(err, "random errors") + }) +} + func TestService_Migrate(t *testing.T) { t.Run("should call repository Migrate method and return result", func(t *testing.T) { repositoryMock := &MockExchangeRepository{} diff --git a/pkg/slacknotifier/error.go b/pkg/slacknotifier/error.go new file mode 100644 index 00000000..229680b6 --- /dev/null +++ b/pkg/slacknotifier/error.go @@ -0,0 +1,41 @@ +package slacknotifier + +type NoChannelFoundErr struct { + Err error +} + +type UserLookupByEmailErr struct { + Err error +} + +type JoinedChannelFetchErr struct { + Err error +} + +type MsgSendErr struct { + Err error +} + +type SlackNotifierErr struct { + Err error +} + +func (n *NoChannelFoundErr) Error() string { + return n.Err.Error() +} + +func (n *UserLookupByEmailErr) Error() string { + return n.Err.Error() +} + +func (n *JoinedChannelFetchErr) Error() string { + return n.Err.Error() +} + +func (n *MsgSendErr) Error() string { + return n.Err.Error() +} + +func (n *SlackNotifierErr) Error() string { + return n.Err.Error() +} diff --git a/pkg/slacknotifier/http.go b/pkg/slacknotifier/http.go new file mode 100644 index 00000000..602b2e06 --- /dev/null +++ b/pkg/slacknotifier/http.go @@ -0,0 +1,107 @@ +package slacknotifier + +import ( + "fmt" + "github.com/pkg/errors" + "github.com/slack-go/slack" +) + +type SlackCaller interface { + SendMessage(string, ...slack.MsgOption) (string, string, string, error) + GetConversations(*slack.GetConversationsParameters) ([]slack.Channel, string, error) + GetUserByEmail(string) (*slack.User, error) + JoinConversation(string) (*slack.Channel, string, []string, error) + GetConversationsForUser(params *slack.GetConversationsForUserParameters) (channels []slack.Channel, nextCursor string, err error) +} + +type SlackNotifierClient struct { + Client SlackCaller +} + +func NewSlackNotifierClient() SlackNotifier { + return &SlackNotifierClient{ + Client: nil, + } +} + +// Notify function takes value receiver because we don't want to share r.client with concurrent requests +func (r SlackNotifierClient) Notify(message *SlackMessage, token string) error { + r.Client = createNewSlackClient(token) + return notifyWithClient(message, r.Client) +} + +var createNewSlackClient = newSlackClient + +func newSlackClient(token string) SlackCaller { + return slack.New(token) +} + +func notifyWithClient(message *SlackMessage, client SlackCaller) error { + var channelID string + switch message.ReceiverType { + case "channel": + joinedChannelList, err := getJoinedChannelsList(client) + if err != nil { + return &JoinedChannelFetchErr{ + Err: errors.Wrap(err, "failed to fetch joined channel list"), + } + } + channelID = searchChannelId(joinedChannelList, message.ReceiverName) + if channelID == "" { + return &NoChannelFoundErr{ + Err: errors.New(fmt.Sprintf("app is not part of the channel %s", message.ReceiverName)), + } + } + case "user": + user, err := client.GetUserByEmail(message.ReceiverName) + if err != nil { + if err.Error() == "users_not_found" { + return &UserLookupByEmailErr{ + Err: errors.Wrap(err, fmt.Sprintf("failed to get id for %s", message.ReceiverName)), + } + } + return &SlackNotifierErr{ + Err: err, + } + } + channelID = user.ID + } + _, _, _, err := client.SendMessage(channelID, slack.MsgOptionText(message.Message, false)) + if err != nil { + return &MsgSendErr{ + Err: errors.Wrap(err, fmt.Sprintf("failed to send message to %s", message.ReceiverName)), + } + } + return nil +} + +func getJoinedChannelsList(s SlackCaller) ([]slack.Channel, error) { + channelList := make([]slack.Channel, 0) + curr := "" + for { + channels, nextCursor, err := s.GetConversationsForUser(&slack.GetConversationsForUserParameters{ + Types: []string{"public_channel", "private_channel"}, + Cursor: curr, + Limit: 1000}) + if err != nil { + return channelList, err + } + for _, c := range channels { + channelList = append(channelList, c) + } + curr = nextCursor + if curr == "" { + break + } + } + return channelList, nil +} + +func searchChannelId(channels []slack.Channel, channelName string) string { + for _, c := range channels { + if c.Name == channelName { + return c.ID + } + } + return "" +} diff --git a/pkg/slacknotifier/http_test.go b/pkg/slacknotifier/http_test.go new file mode 100644 index 00000000..431db5bf --- /dev/null +++ b/pkg/slacknotifier/http_test.go @@ -0,0 +1,263 @@ +package slacknotifier + +import ( + "errors" + "github.com/slack-go/slack" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + "testing" +) + +type SlackHTTPClientTestSuite struct { + suite.Suite +} + +func TestHTTP(t *testing.T) { + suite.Run(t, new(SlackHTTPClientTestSuite)) +} + +func (s *SlackHTTPClientTestSuite) SetupTest() {} + +func (s *SlackHTTPClientTestSuite) TestSlackHTTPClient_Notify() { + s.Run("should notify user identified by their email", func() { + testNotifierClient := NewSlackNotifierClient() + oldClientCreator := createNewSlackClient + defer func() { + createNewSlackClient = oldClientCreator + }() + mockedSlackClient := &SlackClientMock{} + mockedSlackClient.On("GetUserByEmail", "foo@odpf.io").Return(&slack.User{ID: "U20"}, nil) + mockedSlackClient.On("SendMessage", "U20", + mock.AnythingOfType("slack.MsgOption")).Return("", "", "", nil) + createNewSlackClient = func(token string) SlackCaller { + s.Equal("foo_bar", token) + return mockedSlackClient + } + dummyMessage := &SlackMessage{ + ReceiverName: "foo@odpf.io", + ReceiverType: "user", + Message: "random text", + Entity: "odpf", + } + err := testNotifierClient.Notify(dummyMessage, "foo_bar") + s.Nil(err) + mockedSlackClient.AssertExpectations(s.T()) + }) + + s.Run("should return error if notifying user fails", func() { + testNotifierClient := NewSlackNotifierClient() + oldClientCreator := createNewSlackClient + defer func() { + createNewSlackClient = oldClientCreator + }() + mockedSlackClient := &SlackClientMock{} + mockedSlackClient.On("GetUserByEmail", "foo@odpf.io").Return(&slack.User{ID: "U20"}, nil) + mockedSlackClient.On("SendMessage", "U20", + mock.AnythingOfType("slack.MsgOption")).Return("", "", "", errors.New("random error")) + createNewSlackClient = func(token string) SlackCaller { + s.Equal("foo_bar", token) + return mockedSlackClient + } + dummyMessage := &SlackMessage{ + ReceiverName: "foo@odpf.io", + ReceiverType: "user", + Message: "random text", + Entity: "odpf", + } + err := testNotifierClient.Notify(dummyMessage, "foo_bar") + s.EqualError(err, "failed to send message to foo@odpf.io: random error") + mockedSlackClient.AssertExpectations(s.T()) + }) + + s.Run("should return error if user lookup by email fails", func() { + testNotifierClient := NewSlackNotifierClient() + oldClientCreator := createNewSlackClient + defer func() { + createNewSlackClient = oldClientCreator + }() + mockedSlackClient := &SlackClientMock{} + mockedSlackClient.On("GetUserByEmail", "foo@odpf.io"). + Return(nil, errors.New("users_not_found")) + createNewSlackClient = func(token string) SlackCaller { + s.Equal("foo_bar", token) + return mockedSlackClient + } + dummyMessage := &SlackMessage{ + ReceiverName: "foo@odpf.io", + ReceiverType: "user", + Message: "random text", + Entity: "odpf", + } + err := testNotifierClient.Notify(dummyMessage, "foo_bar") + s.EqualError(err, "failed to get id for foo@odpf.io: users_not_found") + mockedSlackClient.AssertExpectations(s.T()) + }) + + s.Run("should return error if user lookup by email fails", func() { + testNotifierClient := NewSlackNotifierClient() + oldClientCreator := createNewSlackClient + defer func() { + createNewSlackClient = oldClientCreator + }() + mockedSlackClient := &SlackClientMock{} + mockedSlackClient.On("GetUserByEmail", "foo@odpf.io"). + Return(nil, errors.New("random error")) + createNewSlackClient = func(token string) SlackCaller { + s.Equal("foo_bar", token) + return mockedSlackClient + } + dummyMessage := &SlackMessage{ + ReceiverName: "foo@odpf.io", + ReceiverType: "user", + Message: "random text", + Entity: "odpf", + } + err := testNotifierClient.Notify(dummyMessage, "foo_bar") + s.EqualError(err, "random error") + mockedSlackClient.AssertExpectations(s.T()) + }) + + s.Run("should notify if part of the channel", func() { + testNotifierClient := NewSlackNotifierClient() + oldClientCreator := createNewSlackClient + defer func() { + createNewSlackClient = oldClientCreator + }() + mockedSlackClient := &SlackClientMock{} + mockedSlackClient.On("GetConversationsForUser", mock.AnythingOfType("*slack.GetConversationsForUserParameters")).Run(func(args mock.Arguments) { + rarg := args.Get(0) + s.Require().IsType((*slack.GetConversationsForUserParameters)(nil), rarg) + r := rarg.(*slack.GetConversationsForUserParameters) + s.Equal(1000, r.Limit) + s.Equal([]string{"public_channel", "private_channel"}, r.Types) + s.Equal("", r.Cursor) + }).Return([]slack.Channel{ + {GroupConversation: slack.GroupConversation{ + Name: "foo", + Conversation: slack.Conversation{ID: "C01"}}, + }}, "nextCurr", nil).Once() + + mockedSlackClient.On("GetConversationsForUser", mock.AnythingOfType("*slack.GetConversationsForUserParameters")).Run(func(args mock.Arguments) { + rarg := args.Get(0) + s.Require().IsType((*slack.GetConversationsForUserParameters)(nil), rarg) + r := rarg.(*slack.GetConversationsForUserParameters) + s.Equal(1000, r.Limit) + s.Equal([]string{"public_channel", "private_channel"}, r.Types) + s.Equal("nextCurr", r.Cursor) + }).Return([]slack.Channel{ + {GroupConversation: slack.GroupConversation{ + Name: "bar", + Conversation: slack.Conversation{ID: "C02"}}, + }}, "", nil) + + mockedSlackClient.On("SendMessage", "C01", + mock.AnythingOfType("slack.MsgOption")).Return("", "", "", nil) + createNewSlackClient = func(token string) SlackCaller { + s.Equal("foo_bar", token) + return mockedSlackClient + } + dummyMessage := &SlackMessage{ + ReceiverName: "foo", + ReceiverType: "channel", + Message: "random text", + Entity: "odpf", + } + err := testNotifierClient.Notify(dummyMessage, "foo_bar") + s.Nil(err) + mockedSlackClient.AssertNumberOfCalls(s.T(), "GetConversationsForUser", 2) + }) + + s.Run("should return error if not part of the channel", func() { + testNotifierClient := NewSlackNotifierClient() + oldClientCreator := createNewSlackClient + defer func() { + createNewSlackClient = oldClientCreator + }() + mockedSlackClient := &SlackClientMock{} + mockedSlackClient.On("GetConversationsForUser", mock.AnythingOfType("*slack.GetConversationsForUserParameters")).Run(func(args mock.Arguments) { + rarg := args.Get(0) + s.Require().IsType((*slack.GetConversationsForUserParameters)(nil), rarg) + r := rarg.(*slack.GetConversationsForUserParameters) + s.Equal(1000, r.Limit) + s.Equal([]string{"public_channel", "private_channel"}, r.Types) + s.Equal("", r.Cursor) + }).Return([]slack.Channel{ + {GroupConversation: slack.GroupConversation{ + Name: "foo", + Conversation: slack.Conversation{ID: "C01"}}, + }}, "nextCurr", nil).Once() + + mockedSlackClient.On("GetConversationsForUser", mock.AnythingOfType("*slack.GetConversationsForUserParameters")).Run(func(args mock.Arguments) { + rarg := args.Get(0) + s.Require().IsType((*slack.GetConversationsForUserParameters)(nil), rarg) + r := rarg.(*slack.GetConversationsForUserParameters) + s.Equal(1000, r.Limit) + s.Equal([]string{"public_channel", "private_channel"}, r.Types) + s.Equal("nextCurr", r.Cursor) + }).Return([]slack.Channel{ + {GroupConversation: slack.GroupConversation{ + Name: "bar", + Conversation: slack.Conversation{ID: "C02"}}, + }}, "", nil) + + mockedSlackClient.On("SendMessage", "C01", + mock.AnythingOfType("slack.MsgOption")).Return("", "", "", nil) + createNewSlackClient = func(token string) SlackCaller { + s.Equal("foo_bar", token) + return mockedSlackClient + } + dummyMessage := &SlackMessage{ + ReceiverName: "baz", + ReceiverType: "channel", + Message: "random text", + Entity: "odpf", + } + err := testNotifierClient.Notify(dummyMessage, "foo_bar") + s.EqualError(err, "app is not part of the channel baz") + }) + + s.Run("should return error failed to fetch joined channels list", func() { + testNotifierClient := NewSlackNotifierClient() + oldClientCreator := createNewSlackClient + defer func() { + createNewSlackClient = oldClientCreator + }() + mockedSlackClient := &SlackClientMock{} + mockedSlackClient.On("GetConversationsForUser", mock.AnythingOfType("*slack.GetConversationsForUserParameters")).Run(func(args mock.Arguments) { + rarg := args.Get(0) + s.Require().IsType((*slack.GetConversationsForUserParameters)(nil), rarg) + r := rarg.(*slack.GetConversationsForUserParameters) + s.Equal(1000, r.Limit) + s.Equal([]string{"public_channel", "private_channel"}, r.Types) + s.Equal("", r.Cursor) + }).Return([]slack.Channel{ + {GroupConversation: slack.GroupConversation{ + Name: "foo", + Conversation: slack.Conversation{ID: "C01"}}, + }}, "nextCurr", nil).Once() + + mockedSlackClient.On("GetConversationsForUser", mock.AnythingOfType("*slack.GetConversationsForUserParameters")).Run(func(args mock.Arguments) { + rarg := args.Get(0) + s.Require().IsType((*slack.GetConversationsForUserParameters)(nil), rarg) + r := rarg.(*slack.GetConversationsForUserParameters) + s.Equal(1000, r.Limit) + s.Equal([]string{"public_channel", "private_channel"}, r.Types) + s.Equal("nextCurr", r.Cursor) + }).Return([]slack.Channel{}, "", errors.New("random error")) + + mockedSlackClient.On("SendMessage", "C01", + mock.AnythingOfType("slack.MsgOption")).Return("", "", "", nil) + createNewSlackClient = func(token string) SlackCaller { + s.Equal("foo_bar", token) + return mockedSlackClient + } + dummyMessage := &SlackMessage{ + ReceiverName: "baz", + ReceiverType: "channel", + Message: "random text", + Entity: "odpf", + } + err := testNotifierClient.Notify(dummyMessage, "foo_bar") + s.EqualError(err, "failed to fetch joined channel list: random error") + }) +} diff --git a/pkg/slacknotifier/model.go b/pkg/slacknotifier/model.go new file mode 100644 index 00000000..4441c822 --- /dev/null +++ b/pkg/slacknotifier/model.go @@ -0,0 +1,22 @@ +package slacknotifier + +import "github.com/odpf/siren/domain" + +type SlackMessage struct { + ReceiverName string `json:"receiver_name"` + ReceiverType string `json:"receiver_type"` + Entity string `json:"entity"` + Message string `json:"message"` +} + +func (message *SlackMessage) fromDomain(m *domain.SlackMessage) *SlackMessage { + message.ReceiverType = m.ReceiverType + message.ReceiverName = m.ReceiverName + message.Entity = m.Entity + message.Message = m.Message + return message +} + +type SlackNotifier interface { + Notify(*SlackMessage, string) error +} diff --git a/pkg/slacknotifier/service.go b/pkg/slacknotifier/service.go new file mode 100644 index 00000000..7bd30227 --- /dev/null +++ b/pkg/slacknotifier/service.go @@ -0,0 +1,40 @@ +package slacknotifier + +import ( + "fmt" + "github.com/odpf/siren/domain" + "github.com/odpf/siren/pkg/codeexchange" + "github.com/pkg/errors" + "gorm.io/gorm" +) + +type Service struct { + client SlackNotifier + codeExchangeService domain.CodeExchangeService +} + +func (s Service) Notify(message *domain.SlackMessage) (*domain.SlackMessageSendResponse, error) { + m := &SlackMessage{} + m = m.fromDomain(message) + token, err := s.codeExchangeService.GetToken(message.Entity) + res := &domain.SlackMessageSendResponse{ + OK: false, + } + if err != nil { + return res, errors.Wrap(err, fmt.Sprintf("could not get token for entity: %s", message.Entity)) + } + err = s.client.Notify(m, token) + if err != nil { + return res, errors.Wrap(err, fmt.Sprintf("could not send notification")) + } + res.OK = true + return res, nil +} + +func NewService(db *gorm.DB, encryptionKey string) (domain.SlackNotifierService, error) { + svc, err := codeexchange.NewService(db, nil, domain.SlackApp{}, encryptionKey) + if err != nil { + return nil, errors.Wrap(err, "failed to init slack notifier service") + } + return &Service{client: NewSlackNotifierClient(), codeExchangeService: svc}, nil +} diff --git a/pkg/slacknotifier/service_test.go b/pkg/slacknotifier/service_test.go new file mode 100644 index 00000000..6e4076da --- /dev/null +++ b/pkg/slacknotifier/service_test.go @@ -0,0 +1,93 @@ +package slacknotifier + +import ( + "errors" + "github.com/odpf/siren/domain" + "github.com/odpf/siren/mocks" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + "testing" +) + +type ServiceTestSuite struct { + suite.Suite + notifierMock *MockSlackNotifier + exchangerMock *mocks.CodeExchangeService +} + +func (s *ServiceTestSuite) SetupTest() { + s.notifierMock = &MockSlackNotifier{} + s.exchangerMock = &mocks.CodeExchangeService{} +} + +func TestService(t *testing.T) { + suite.Run(t, new(ServiceTestSuite)) +} + +func (s *ServiceTestSuite) TestService_Notify() { + dummyMessage := &domain.SlackMessage{ + ReceiverName: "foo", + ReceiverType: "user", + Message: "some text", + Entity: "odpf", + } + dummyService := Service{ + client: s.notifierMock, + codeExchangeService: s.exchangerMock, + } + s.Run("should call notifier and return success response", func() { + s.exchangerMock.On("GetToken", "odpf").Return("test_token", nil).Once() + s.notifierMock.On("Notify", mock.AnythingOfType("*slacknotifier.SlackMessage"), "test_token"). + Run(func(args mock.Arguments) { + rarg := args.Get(0) + s.Require().IsType((*SlackMessage)(nil), rarg) + r := rarg.(*SlackMessage) + s.Equal("foo", r.ReceiverName) + s.Equal("user", r.ReceiverType) + s.Equal("some text", r.Message) + s.Equal("odpf", r.Entity) + }).Return(nil).Once() + + res, err := dummyService.Notify(dummyMessage) + s.Equal(true, res.OK) + s.Nil(err) + s.notifierMock.AssertExpectations(s.T()) + s.exchangerMock.AssertExpectations(s.T()) + }) + + s.Run("should return error response if notifying fails", func() { + s.exchangerMock.On("GetToken", "odpf").Return("test_token", nil).Once() + s.notifierMock.On("Notify", mock.AnythingOfType("*slacknotifier.SlackMessage"), "test_token"). + Return(errors.New("random error")).Once() + + res, err := dummyService.Notify(dummyMessage) + s.Equal(false, res.OK) + s.EqualError(err, "could not send notification: random error") + s.notifierMock.AssertExpectations(s.T()) + s.exchangerMock.AssertExpectations(s.T()) + }) + + s.Run("should return error response if getting token fails", func() { + s.exchangerMock.On("GetToken", "odpf"). + Return("", errors.New("random token")).Once() + + res, err := dummyService.Notify(dummyMessage) + s.Equal(false, res.OK) + s.EqualError(err, "could not get token for entity: odpf: random token") + s.exchangerMock.AssertExpectations(s.T()) + }) +} + +func (s *ServiceTestSuite) TestService_NewService() { + s.Run("should return error in service initialization", func() { + res, err := NewService(nil, "rQvRLU4S6NOtJPDBC0ybemgiU710twcN") + s.Nil(err) + s.NotNil(res) + }) + + s.Run("should return error in service initialization", func() { + res, err := NewService(nil, "abcd") + s.EqualError(err, `failed to init slack notifier service: failed to create codeexchange repository: random hash should be 32 chars in length`) + s.Nil(res) + }) +} diff --git a/pkg/slacknotifier/slackcaller_mock.go b/pkg/slacknotifier/slackcaller_mock.go new file mode 100644 index 00000000..6dafad4d --- /dev/null +++ b/pkg/slacknotifier/slackcaller_mock.go @@ -0,0 +1,177 @@ +// Code generated by mockery 2.9.0. DO NOT EDIT. + +package slacknotifier + +import ( + slack "github.com/slack-go/slack" + mock "github.com/stretchr/testify/mock" +) + +// SlackCaller is an autogenerated mock type for the SlackCaller type +type SlackClientMock struct { + mock.Mock +} + +// GetConversations provides a mock function with given fields: _a0 +func (_m *SlackClientMock) GetConversations(_a0 *slack.GetConversationsParameters) ([]slack.Channel, string, error) { + ret := _m.Called(_a0) + + var r0 []slack.Channel + if rf, ok := ret.Get(0).(func(*slack.GetConversationsParameters) []slack.Channel); ok { + r0 = rf(_a0) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]slack.Channel) + } + } + + var r1 string + if rf, ok := ret.Get(1).(func(*slack.GetConversationsParameters) string); ok { + r1 = rf(_a0) + } else { + r1 = ret.Get(1).(string) + } + + var r2 error + if rf, ok := ret.Get(2).(func(*slack.GetConversationsParameters) error); ok { + r2 = rf(_a0) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// GetConversationsForUser provides a mock function with given fields: params +func (_m *SlackClientMock) GetConversationsForUser(params *slack.GetConversationsForUserParameters) ([]slack.Channel, string, error) { + ret := _m.Called(params) + + var r0 []slack.Channel + if rf, ok := ret.Get(0).(func(*slack.GetConversationsForUserParameters) []slack.Channel); ok { + r0 = rf(params) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]slack.Channel) + } + } + + var r1 string + if rf, ok := ret.Get(1).(func(*slack.GetConversationsForUserParameters) string); ok { + r1 = rf(params) + } else { + r1 = ret.Get(1).(string) + } + + var r2 error + if rf, ok := ret.Get(2).(func(*slack.GetConversationsForUserParameters) error); ok { + r2 = rf(params) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// GetUserByEmail provides a mock function with given fields: _a0 +func (_m *SlackClientMock) GetUserByEmail(_a0 string) (*slack.User, error) { + ret := _m.Called(_a0) + + var r0 *slack.User + if rf, ok := ret.Get(0).(func(string) *slack.User); ok { + r0 = rf(_a0) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*slack.User) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// JoinConversation provides a mock function with given fields: _a0 +func (_m *SlackClientMock) JoinConversation(_a0 string) (*slack.Channel, string, []string, error) { + ret := _m.Called(_a0) + + var r0 *slack.Channel + if rf, ok := ret.Get(0).(func(string) *slack.Channel); ok { + r0 = rf(_a0) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*slack.Channel) + } + } + + var r1 string + if rf, ok := ret.Get(1).(func(string) string); ok { + r1 = rf(_a0) + } else { + r1 = ret.Get(1).(string) + } + + var r2 []string + if rf, ok := ret.Get(2).(func(string) []string); ok { + r2 = rf(_a0) + } else { + if ret.Get(2) != nil { + r2 = ret.Get(2).([]string) + } + } + + var r3 error + if rf, ok := ret.Get(3).(func(string) error); ok { + r3 = rf(_a0) + } else { + r3 = ret.Error(3) + } + + return r0, r1, r2, r3 +} + +// SendMessage provides a mock function with given fields: _a0, _a1 +func (_m *SlackClientMock) SendMessage(_a0 string, _a1 ...slack.MsgOption) (string, string, string, error) { + _va := make([]interface{}, len(_a1)) + for _i := range _a1 { + _va[_i] = _a1[_i] + } + var _ca []interface{} + _ca = append(_ca, _a0) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 string + if rf, ok := ret.Get(0).(func(string, ...slack.MsgOption) string); ok { + r0 = rf(_a0, _a1...) + } else { + r0 = ret.Get(0).(string) + } + + var r1 string + if rf, ok := ret.Get(1).(func(string, ...slack.MsgOption) string); ok { + r1 = rf(_a0, _a1...) + } else { + r1 = ret.Get(1).(string) + } + + var r2 string + if rf, ok := ret.Get(2).(func(string, ...slack.MsgOption) string); ok { + r2 = rf(_a0, _a1...) + } else { + r2 = ret.Get(2).(string) + } + + var r3 error + if rf, ok := ret.Get(3).(func(string, ...slack.MsgOption) error); ok { + r3 = rf(_a0, _a1...) + } else { + r3 = ret.Error(3) + } + + return r0, r1, r2, r3 +} diff --git a/pkg/slacknotifier/slacknotifier_mock.go b/pkg/slacknotifier/slacknotifier_mock.go new file mode 100644 index 00000000..503437e9 --- /dev/null +++ b/pkg/slacknotifier/slacknotifier_mock.go @@ -0,0 +1,24 @@ +// Code generated by mockery 2.9.0. DO NOT EDIT. + +package slacknotifier + +import "github.com/stretchr/testify/mock" + +// MockSlackNotifier is an autogenerated mock type for the SlackNotifier type +type MockSlackNotifier struct { + mock.Mock +} + +// Notify provides a mock function with given fields: _a0, _a1 +func (_m *MockSlackNotifier) Notify(_a0 *SlackMessage, _a1 string) error { + ret := _m.Called(_a0, _a1) + + var r0 error + if rf, ok := ret.Get(0).(func(*SlackMessage, string) error); ok { + r0 = rf(_a0, _a1) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/service/container.go b/service/container.go index d6998a88..fb56d4b8 100644 --- a/service/container.go +++ b/service/container.go @@ -10,6 +10,7 @@ import ( "github.com/odpf/siren/pkg/alert_history" "github.com/odpf/siren/pkg/codeexchange" "github.com/odpf/siren/pkg/rules" + "github.com/odpf/siren/pkg/slacknotifier" "github.com/odpf/siren/pkg/templates" "github.com/pkg/errors" "gorm.io/gorm" @@ -21,6 +22,7 @@ type Container struct { AlertmanagerService domain.AlertmanagerService AlertHistoryService domain.AlertHistoryService CodeExchangeService domain.CodeExchangeService + NotifierServices domain.NotifierServices } func Init(db *gorm.DB, c *domain.Config, @@ -37,12 +39,19 @@ func Init(db *gorm.DB, c *domain.Config, if err != nil { return nil, errors.Wrap(err, "failed to create codeexchange service") } + slackNotifierService, err := slacknotifier.NewService(db, c.EncryptionKey) + if err != nil { + return nil, errors.Wrap(err, "failed to create codeexchange service") + } return &Container{ TemplatesService: templatesService, RulesService: rulesService, AlertmanagerService: alertmanagerService, AlertHistoryService: alertHistoryService, CodeExchangeService: codeExchangeService, + NotifierServices: domain.NotifierServices{ + Slack: slackNotifierService, + }, }, nil }