diff --git a/go.mod b/go.mod index d6a1fdbf4..8c649e3eb 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( github.com/francoispqt/gojay v1.2.13 // indirect github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect + github.com/golang/mock v1.6.0 github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/uuid v1.6.0 // indirect diff --git a/go.sum b/go.sum index d83ebca59..cae7b2f16 100644 --- a/go.sum +++ b/go.sum @@ -251,12 +251,14 @@ github.com/wiggin77/srslog v1.0.1 h1:gA2XjSMy3DrRdX9UqLuDtuVAAshb8bE1NhX1YK0Qe+8 github.com/wiggin77/srslog v1.0.1/go.mod h1:fehkyYDq1QfuYn60TDPu9YdY2bB85VUW2mvN1WynEls= github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c h1:3lbZUMbMiGUW/LMkfsEABsc5zNT9+b1CvsJx47JzJ8g= github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c/go.mod h1:UrdRz5enIKZ63MEE3IF9l2/ebyx59GyGgPi+tICQdmM= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= go.opencensus.io v0.18.0/go.mod h1:vKdFvxhtzZ9onBp9VKHK8z/sRpBMnKAsufL7wlDrCOA= go4.org v0.0.0-20180809161055-417644f6feb5/go.mod h1:MkTOUMDaeVYJUOUsaDXIhWPZYa1yOyC1qaOBpL57BhE= golang.org/x/build v0.0.0-20190111050920-041ab4dc3f9d/go.mod h1:OWs+y06UdEOHN4y+MfF/py+xQ/tYqIWW03b70/CG9Rw= golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= @@ -265,6 +267,7 @@ golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -274,7 +277,9 @@ golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190313220215-9f648a60d977/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= @@ -289,6 +294,8 @@ golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181029174526-d69651ed3497/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -298,6 +305,8 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -324,7 +333,12 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20181030000716-a0a13e073c7b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= google.golang.org/api v0.0.0-20181030000543-1d582fd0359e/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= google.golang.org/api v0.1.0/go.mod h1:UGEZY7KEX120AnNLIHFMKIo4obdJhkp2tPbaPlQx13Y= diff --git a/server/mocks/logger_mock.go b/server/mocks/logger_mock.go new file mode 100644 index 000000000..8c3d2ea75 --- /dev/null +++ b/server/mocks/logger_mock.go @@ -0,0 +1,159 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/mattermost/mattermost/server/public/pluginapi/experimental/bot/logger (interfaces: Logger) + +// Package mocks is a generated GoMock package. +package mocks + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + logger "github.com/mattermost/mattermost/server/public/pluginapi/experimental/bot/logger" +) + +// MockLogger is a mock of Logger interface. +type MockLogger struct { + ctrl *gomock.Controller + recorder *MockLoggerMockRecorder +} + +// MockLoggerMockRecorder is the mock recorder for MockLogger. +type MockLoggerMockRecorder struct { + mock *MockLogger +} + +// NewMockLogger creates a new mock instance. +func NewMockLogger(ctrl *gomock.Controller) *MockLogger { + mock := &MockLogger{ctrl: ctrl} + mock.recorder = &MockLoggerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockLogger) EXPECT() *MockLoggerMockRecorder { + return m.recorder +} + +// Context mocks base method. +func (m *MockLogger) Context() logger.LogContext { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Context") + ret0, _ := ret[0].(logger.LogContext) + return ret0 +} + +// Context indicates an expected call of Context. +func (mr *MockLoggerMockRecorder) Context() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Context", reflect.TypeOf((*MockLogger)(nil).Context)) +} + +// Debugf mocks base method. +func (m *MockLogger) Debugf(arg0 string, arg1 ...interface{}) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Debugf", varargs...) +} + +// Debugf indicates an expected call of Debugf. +func (mr *MockLoggerMockRecorder) Debugf(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Debugf", reflect.TypeOf((*MockLogger)(nil).Debugf), varargs...) +} + +// Errorf mocks base method. +func (m *MockLogger) Errorf(arg0 string, arg1 ...interface{}) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Errorf", varargs...) +} + +// Errorf indicates an expected call of Errorf. +func (mr *MockLoggerMockRecorder) Errorf(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Errorf", reflect.TypeOf((*MockLogger)(nil).Errorf), varargs...) +} + +// Infof mocks base method. +func (m *MockLogger) Infof(arg0 string, arg1 ...interface{}) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Infof", varargs...) +} + +// Infof indicates an expected call of Infof. +func (mr *MockLoggerMockRecorder) Infof(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Infof", reflect.TypeOf((*MockLogger)(nil).Infof), varargs...) +} + +// Timed mocks base method. +func (m *MockLogger) Timed() logger.Logger { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Timed") + ret0, _ := ret[0].(logger.Logger) + return ret0 +} + +// Timed indicates an expected call of Timed. +func (mr *MockLoggerMockRecorder) Timed() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Timed", reflect.TypeOf((*MockLogger)(nil).Timed)) +} + +// Warnf mocks base method. +func (m *MockLogger) Warnf(arg0 string, arg1 ...interface{}) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Warnf", varargs...) +} + +// Warnf indicates an expected call of Warnf. +func (mr *MockLoggerMockRecorder) Warnf(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Warnf", reflect.TypeOf((*MockLogger)(nil).Warnf), varargs...) +} + +// With mocks base method. +func (m *MockLogger) With(arg0 logger.LogContext) logger.Logger { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "With", arg0) + ret0, _ := ret[0].(logger.Logger) + return ret0 +} + +// With indicates an expected call of With. +func (mr *MockLoggerMockRecorder) With(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "With", reflect.TypeOf((*MockLogger)(nil).With), arg0) +} + +// WithError mocks base method. +func (m *MockLogger) WithError(arg0 error) logger.Logger { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "WithError", arg0) + ret0, _ := ret[0].(logger.Logger) + return ret0 +} + +// WithError indicates an expected call of WithError. +func (mr *MockLoggerMockRecorder) WithError(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WithError", reflect.TypeOf((*MockLogger)(nil).WithError), arg0) +} diff --git a/server/mocks/mock_KvStore.go b/server/mocks/mock_KvStore.go new file mode 100644 index 000000000..c2c466f09 --- /dev/null +++ b/server/mocks/mock_KvStore.go @@ -0,0 +1,103 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/mattermost/mattermost-plugin-github/server/plugin (interfaces: KvStore) + +// Package mocks is a generated GoMock package. +package mocks + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + pluginapi "github.com/mattermost/mattermost/server/public/pluginapi" +) + +// MockKvStore is a mock of KvStore interface. +type MockKvStore struct { + ctrl *gomock.Controller + recorder *MockKvStoreMockRecorder +} + +// MockKvStoreMockRecorder is the mock recorder for MockKvStore. +type MockKvStoreMockRecorder struct { + mock *MockKvStore +} + +// NewMockKvStore creates a new mock instance. +func NewMockKvStore(ctrl *gomock.Controller) *MockKvStore { + mock := &MockKvStore{ctrl: ctrl} + mock.recorder = &MockKvStoreMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockKvStore) EXPECT() *MockKvStoreMockRecorder { + return m.recorder +} + +// Delete mocks base method. +func (m *MockKvStore) Delete(arg0 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Delete", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// Delete indicates an expected call of Delete. +func (mr *MockKvStoreMockRecorder) Delete(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockKvStore)(nil).Delete), arg0) +} + +// Get mocks base method. +func (m *MockKvStore) Get(arg0 string, arg1 interface{}) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// Get indicates an expected call of Get. +func (mr *MockKvStoreMockRecorder) Get(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockKvStore)(nil).Get), arg0, arg1) +} + +// ListKeys mocks base method. +func (m *MockKvStore) ListKeys(arg0, arg1 int, arg2 ...pluginapi.ListKeysOption) ([]string, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1} + for _, a := range arg2 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "ListKeys", varargs...) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListKeys indicates an expected call of ListKeys. +func (mr *MockKvStoreMockRecorder) ListKeys(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1}, arg2...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListKeys", reflect.TypeOf((*MockKvStore)(nil).ListKeys), varargs...) +} + +// Set mocks base method. +func (m *MockKvStore) Set(arg0 string, arg1 interface{}, arg2 ...pluginapi.KVSetOption) (bool, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1} + for _, a := range arg2 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Set", varargs...) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Set indicates an expected call of Set. +func (mr *MockKvStoreMockRecorder) Set(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1}, arg2...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Set", reflect.TypeOf((*MockKvStore)(nil).Set), varargs...) +} diff --git a/server/plugin/api_test.go b/server/plugin/api_test.go index 782a1487d..0325447fd 100644 --- a/server/plugin/api_test.go +++ b/server/plugin/api_test.go @@ -1,13 +1,19 @@ package plugin import ( + "encoding/json" "io" "net/http" "net/http/httptest" + "strings" "testing" + "github.com/golang/mock/gomock" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "golang.org/x/oauth2" "github.com/mattermost/mattermost/server/public/plugin" "github.com/mattermost/mattermost/server/public/plugin/plugintest" @@ -127,51 +133,70 @@ func TestPlugin_ServeHTTP(t *testing.T) { } func TestGetToken(t *testing.T) { - httpTestString := testutils.HTTPTest{ - T: t, - Encoder: testutils.EncodeString, - } + mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) + p := getPluginTest(mockAPI, mockKvStore) - for name, test := range map[string]struct { - httpTest testutils.HTTPTest - request testutils.Request - context *plugin.Context - expectedResponse testutils.ExpectedResponse + tests := []struct { + name string + userID string + setup func() + assertions func(t *testing.T, rec *httptest.ResponseRecorder) }{ - "not authorized": { - httpTest: httpTestString, - request: testutils.Request{ - Method: http.MethodGet, - URL: "/api/v1/token", - Body: nil, + { + name: "Missing userID", + userID: "", + setup: func() {}, + assertions: func(t *testing.T, rec *httptest.ResponseRecorder) { + assert.Equal(t, http.StatusBadRequest, rec.Result().StatusCode) + body, _ := io.ReadAll(rec.Body) + assert.Equal(t, "please provide a userID\n", string(body)) }, - context: &plugin.Context{}, - expectedResponse: testutils.ExpectedResponse{ - StatusCode: http.StatusUnauthorized, - ResponseType: testutils.ContentTypePlain, - Body: "Not authorized\n", + }, + { + name: "User info not found in store", + userID: "mockUserID", + setup: func() { + mockKvStore.EXPECT().Get("mockUserID"+githubTokenKey, gomock.Any()).Return(errors.New("not found")).Times(1) + }, + assertions: func(t *testing.T, rec *httptest.ResponseRecorder) { + assert.Equal(t, http.StatusInternalServerError, rec.Result().StatusCode) + body, _ := io.ReadAll(rec.Body) + assert.Equal(t, "Unable to get user info.\n", string(body)) }, }, - } { - t.Run(name, func(t *testing.T) { - p := NewPlugin() - p.setConfiguration( - &Configuration{ - GitHubOrg: "mockOrg", - GitHubOAuthClientID: "mockID", - GitHubOAuthClientSecret: "mockSecret", - EncryptionKey: "mockKey", - }) - p.initializeAPI() - - p.SetAPI(&plugintest.API{}) + { + name: "Successful token retrieval", + userID: "mockUserID", + setup: func() { + encryptedToken, err := encrypt([]byte("dummyEncryptKey1"), MockAccessToken) + assert.NoError(t, err) + mockKvStore.EXPECT().Get("mockUserID"+githubTokenKey, gomock.Any()).DoAndReturn(func(key string, value **GitHubUserInfo) error { + *value = &GitHubUserInfo{ + Token: &oauth2.Token{ + AccessToken: encryptedToken, + }, + } + return nil + }).Times(1) + p.setConfiguration(&Configuration{EncryptionKey: "dummyEncryptKey1"}) + }, + assertions: func(t *testing.T, rec *httptest.ResponseRecorder) { + assert.Equal(t, http.StatusOK, rec.Result().StatusCode) + body, _ := io.ReadAll(rec.Body) + assert.Contains(t, string(body), MockAccessToken) + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.setup() - req := test.httpTest.CreateHTTPRequest(test.request) - rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/get/token?userID="+tc.userID, nil) + rec := httptest.NewRecorder() - p.ServeHTTP(test.context, rr, req) + p.getToken(rec, req) - test.httpTest.CompareHTTPResponse(rr, test.expectedResponse) + tc.assertions(t, rec) }) } } @@ -246,3 +271,231 @@ func TestGetConfig(t *testing.T) { }) } } + +func TestGetGitHubUser(t *testing.T) { + mockKvStore, mockAPI, mockLogger, mockLoggerWith, mockContext := GetTestSetup(t) + p := getPluginTest(mockAPI, mockKvStore) + + tests := []struct { + name string + requestBody string + setup func() + expectedStatusCode int + assertions func(t *testing.T, rec *httptest.ResponseRecorder) + }{ + { + name: "Invalid JSON Request Body", + requestBody: "invalid-json", + setup: func() { + mockLogger.EXPECT().WithError(gomock.Any()).Return(mockLoggerWith).Times(1) + mockLoggerWith.EXPECT().Warnf("Error decoding GitHubUserRequest from JSON body").Times(1) + }, + assertions: func(t *testing.T, rec *httptest.ResponseRecorder) { + assert.Equal(t, http.StatusBadRequest, rec.Result().StatusCode) + + var response APIErrorResponse + _ = json.NewDecoder(rec.Body).Decode(&response) + assert.Contains(t, response.Message, "Please provide a JSON object.") + }, + }, + { + name: "Blank user_id field", + requestBody: `{"user_id": ""}`, + setup: func() {}, + assertions: func(t *testing.T, rec *httptest.ResponseRecorder) { + assert.Equal(t, http.StatusBadRequest, rec.Result().StatusCode) + var response APIErrorResponse + _ = json.NewDecoder(rec.Body).Decode(&response) + assert.Contains(t, response.Message, "non-blank user_id field") + }, + }, + { + name: "Error to getting user info", + requestBody: `{"user_id": "mockUserID"}`, + setup: func() { + mockKvStore.EXPECT().Get(gomock.Any(), gomock.Any()).Return(errors.New("Error getting user details")).Times(1) + }, + assertions: func(t *testing.T, rec *httptest.ResponseRecorder) { + assert.Equal(t, http.StatusInternalServerError, rec.Result().StatusCode) + var response APIErrorResponse + _ = json.NewDecoder(rec.Body).Decode(&response) + assert.Contains(t, response.Message, "Unable to get user info") + }, + }, + { + name: "User is not connected to a GitHub account.", + requestBody: `{"user_id": "mockUserID"}`, + setup: func() { + mockKvStore.EXPECT().Get(gomock.Any(), gomock.Any()).Return(nil).Times(1) + }, + assertions: func(t *testing.T, rec *httptest.ResponseRecorder) { + assert.Equal(t, http.StatusNotFound, rec.Result().StatusCode) + + var response APIErrorResponse + _ = json.NewDecoder(rec.Body).Decode(&response) + assert.Contains(t, response.Message, "User is not connected to a GitHub account.") + }, + }, + { + name: "Successfully get github user", + requestBody: `{"user_id": "mockUserID"}`, + setup: func() { + dummyUserInfo, err := GetMockGHUserInfo(p) + assert.NoError(t, err) + mockKvStore.EXPECT().Get("mockUserID"+githubTokenKey, gomock.Any()).DoAndReturn(func(key string, value **GitHubUserInfo) error { + *value = dummyUserInfo + return nil + }).Times(1) + }, + assertions: func(t *testing.T, rec *httptest.ResponseRecorder) { + assert.Equal(t, http.StatusOK, rec.Result().StatusCode) + var response GitHubUserResponse + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Equal(t, MockUsername, response.Username) + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.setup() + + req := httptest.NewRequest(http.MethodPost, "/github/user", strings.NewReader(tc.requestBody)) + rec := httptest.NewRecorder() + + p.getGitHubUser(mockContext, rec, req) + + tc.assertions(t, rec) + }) + } +} + +func TestParseRepo(t *testing.T) { + tests := []struct { + name string + repoParam string + setup func() + assertions func(t *testing.T, owner, repo string, err error) + }{ + { + name: "Empty repository parameter", + repoParam: "", + setup: func() {}, + assertions: func(t *testing.T, owner, repo string, err error) { + assert.Equal(t, "", owner) + assert.Equal(t, "", repo) + assert.EqualError(t, err, "repository cannot be blank") + }, + }, + { + name: "Invalid repository format", + repoParam: "owner", + setup: func() {}, + assertions: func(t *testing.T, owner, repo string, err error) { + assert.Equal(t, "", owner) + assert.Equal(t, "", repo) + assert.EqualError(t, err, "invalid repository") + }, + }, + { + name: "Valid repository format", + repoParam: "owner/repo", + setup: func() {}, + assertions: func(t *testing.T, owner, repo string, err error) { + assert.NoError(t, err) + assert.Equal(t, "owner", owner) + assert.Equal(t, "repo", repo) + }, + }, + { + name: "Extra slashes in repository parameter", + repoParam: "owner/repo/", + setup: func() {}, + assertions: func(t *testing.T, owner, repo string, err error) { + assert.Equal(t, "", owner) + assert.Equal(t, "", repo) + assert.EqualError(t, err, "invalid repository") + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.setup() + + owner, repo, err := parseRepo(tc.repoParam) + + tc.assertions(t, owner, repo, err) + }) + } +} + +func TestUpdateSettings(t *testing.T) { + mockKvStore, mockAPI, mockLogger, mockLoggerWith, _ := GetTestSetup(t) + p := getPluginTest(mockAPI, mockKvStore) + mockGHContext, err := GetMockUserContext(p, mockLogger) + assert.NoError(t, err) + + tests := []struct { + name string + requestBody string + setup func() + expectedStatusCode int + assertions func(t *testing.T, rec *httptest.ResponseRecorder) + }{ + { + name: "Invalid JSON Request Body", + requestBody: "invalid-json", + setup: func() { + mockLogger.EXPECT().WithError(gomock.Any()).Return(mockLoggerWith).Times(1) + mockLoggerWith.EXPECT().Warnf("Error decoding settings from JSON body").Times(1) + }, + expectedStatusCode: http.StatusBadRequest, + assertions: func(t *testing.T, rec *httptest.ResponseRecorder) { + assert.Equal(t, http.StatusBadRequest, rec.Result().StatusCode) + }, + }, + { + name: "Error Storing User Info", + requestBody: `{"access_token": "mockAccessToken"}`, + setup: func() { + p.setConfiguration(&Configuration{ + EncryptionKey: "dummyEncryptKey1", + }) + mockKvStore.EXPECT().Set(gomock.Any(), gomock.Any()).Return(false, errors.New("store error")).Times(1) + mockLogger.EXPECT().WithError(gomock.Any()).Return(mockLoggerWith).Times(1) + mockLoggerWith.EXPECT().Warnf("Failed to store GitHub user info").Times(1) + }, + expectedStatusCode: http.StatusInternalServerError, + assertions: func(t *testing.T, rec *httptest.ResponseRecorder) { + assert.Equal(t, http.StatusInternalServerError, rec.Result().StatusCode) + }, + }, + { + name: "Successful Update", + requestBody: `{"access_token": "mockAccessToken"}`, + setup: func() { + mockKvStore.EXPECT().Set(gomock.Any(), gomock.Any()).Return(true, nil).Times(1) + }, + expectedStatusCode: http.StatusOK, + assertions: func(t *testing.T, rec *httptest.ResponseRecorder) { + assert.Equal(t, http.StatusOK, rec.Result().StatusCode) + var settings UserSettings + err := json.NewDecoder(rec.Body).Decode(&settings) + assert.NoError(t, err) + assert.Equal(t, mockGHContext.GHInfo.Settings, &settings) + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.setup() + + req := httptest.NewRequest(http.MethodPost, "/update/settings", strings.NewReader(tc.requestBody)) + rec := httptest.NewRecorder() + + p.updateSettings(mockGHContext, rec, req) + + tc.assertions(t, rec) + }) + } +} diff --git a/server/plugin/command.go b/server/plugin/command.go index 6f632583d..cad6ab0bc 100644 --- a/server/plugin/command.go +++ b/server/plugin/command.go @@ -151,7 +151,7 @@ func (p *Plugin) getMutedUsernames(userInfo *GitHubUserInfo) []string { return mutedUsers } -func (p *Plugin) handleMuteList(args *model.CommandArgs, userInfo *GitHubUserInfo) string { +func (p *Plugin) handleMuteList(_ *model.CommandArgs, userInfo *GitHubUserInfo) string { mutedUsernames := p.getMutedUsernames(userInfo) var mutedUsers string for _, user := range mutedUsernames { @@ -172,7 +172,7 @@ func contains(s []string, e string) bool { return false } -func (p *Plugin) handleMuteAdd(args *model.CommandArgs, username string, userInfo *GitHubUserInfo) string { +func (p *Plugin) handleMuteAdd(_ *model.CommandArgs, username string, userInfo *GitHubUserInfo) string { mutedUsernames := p.getMutedUsernames(userInfo) if contains(mutedUsernames, username) { return username + " is already muted" @@ -198,7 +198,7 @@ func (p *Plugin) handleMuteAdd(args *model.CommandArgs, username string, userInf return fmt.Sprintf("`%v`", username) + " is now muted. You'll no longer receive notifications for comments in your PRs and issues." } -func (p *Plugin) handleUnmute(args *model.CommandArgs, username string, userInfo *GitHubUserInfo) string { +func (p *Plugin) handleUnmute(_ *model.CommandArgs, username string, userInfo *GitHubUserInfo) string { mutedUsernames := p.getMutedUsernames(userInfo) userToMute := []string{username} newMutedList := arrayDifference(mutedUsernames, userToMute) @@ -211,7 +211,7 @@ func (p *Plugin) handleUnmute(args *model.CommandArgs, username string, userInfo return fmt.Sprintf("`%v`", username) + " is no longer muted" } -func (p *Plugin) handleUnmuteAll(args *model.CommandArgs, userInfo *GitHubUserInfo) string { +func (p *Plugin) handleUnmuteAll(_ *model.CommandArgs, userInfo *GitHubUserInfo) string { _, err := p.store.Set(userInfo.UserID+"-muted-users", []byte("")) if err != nil { return "Error occurred unmuting users" @@ -293,7 +293,7 @@ func (p *Plugin) handleSubscriptions(c *plugin.Context, args *model.CommandArgs, } } -func (p *Plugin) handleSubscriptionsList(_ *plugin.Context, args *model.CommandArgs, parameters []string, _ *GitHubUserInfo) string { +func (p *Plugin) handleSubscriptionsList(_ *plugin.Context, args *model.CommandArgs, _ []string, _ *GitHubUserInfo) string { txt := "" subs, err := p.GetSubscriptionsByChannel(args.ChannelId) if err != nil { @@ -538,6 +538,7 @@ func (p *Plugin) getSubscribedFeatures(channelID, owner, repo string) (Features, return previousFeatures, nil } + func (p *Plugin) handleUnsubscribe(_ *plugin.Context, args *model.CommandArgs, parameters []string, _ *GitHubUserInfo) string { if len(parameters) == 0 { return "Please specify a repository." @@ -706,7 +707,7 @@ func (p *Plugin) handleIssue(_ *plugin.Context, args *model.CommandArgs, paramet } } -func (p *Plugin) handleSetup(c *plugin.Context, args *model.CommandArgs, parameters []string) string { +func (p *Plugin) handleSetup(_ *plugin.Context, args *model.CommandArgs, parameters []string) string { userID := args.UserId isSysAdmin, err := p.isAuthorizedSysAdmin(userID) if err != nil { diff --git a/server/plugin/command_test.go b/server/plugin/command_test.go index 5b9970190..3da1e3e25 100644 --- a/server/plugin/command_test.go +++ b/server/plugin/command_test.go @@ -4,9 +4,39 @@ import ( "fmt" "testing" + "github.com/golang/mock/gomock" + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/plugin/plugintest" + "github.com/mattermost/mattermost/server/public/pluginapi" + "github.com/pkg/errors" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" + + "github.com/mattermost/mattermost-plugin-github/server/mocks" ) +// Function to get the plugin object for test cases. +func getPluginTest(api *plugintest.API, mockKvStore *mocks.MockKvStore) *Plugin { + p := NewPlugin() + p.setConfiguration( + &Configuration{ + GitHubOrg: "mockOrg", + GitHubOAuthClientID: "mockID", + GitHubOAuthClientSecret: "mockSecret", + EncryptionKey: "mockKey123456789", + }) + p.initializeAPI() + p.store = mockKvStore + p.BotUserID = MockBotID + p.SetAPI(api) + p.client = pluginapi.NewClient(api, p.Driver) + + return p +} + func TestValidateFeatures(t *testing.T) { type output struct { valid bool @@ -239,3 +269,1336 @@ func TestCheckConflictingFeatures(t *testing.T) { }) } } + +func TestExecuteCommand(t *testing.T) { + tests := map[string]struct { + commandArgs *model.CommandArgs + expectedMsg string + SetupMockStore func(*mocks.MockKvStore) + }{ + "about command": { + commandArgs: &model.CommandArgs{Command: "/github about"}, + expectedMsg: "GitHub version", + SetupMockStore: func(mks *mocks.MockKvStore) {}, + }, + + "help command": { + commandArgs: &model.CommandArgs{Command: "/github help", ChannelId: "test-channelId", RootId: "test-rootId", UserId: "test-userId"}, + expectedMsg: "###### Mattermost GitHub Plugin - Slash Command Help\n", + SetupMockStore: func(mks *mocks.MockKvStore) { + mks.EXPECT().Get(gomock.Any(), gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + // Cast the value to the appropriate type and updated it + if userInfoPtr, ok := value.(**GitHubUserInfo); ok { + *userInfoPtr = &GitHubUserInfo{ + // Mock user info data + Token: &oauth2.Token{ + AccessToken: "ycbODW-BWbNBGfF7ac4T5RL5ruNm5BChCXgbkY1bWHqMt80JTkLsicQwo8de3tqfqlfMaglpgjqGOmSHeGp0dA==", + }, + } + } + return nil // no error, so return nil + }) + }, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + isSendEphemeralPostCalled := false + + // Controller for the mocks generated using mockgen + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockKvStore := mocks.NewMockKvStore(mockCtrl) + + tt.SetupMockStore(mockKvStore) + + currentTestAPI := &plugintest.API{} + currentTestAPI.On("SendEphemeralPost", mock.AnythingOfType("string"), mock.AnythingOfType("*model.Post")).Run(func(args mock.Arguments) { + isSendEphemeralPostCalled = true + + post := args.Get(1).(*model.Post) + // Checking the contents of the post + assert.Contains(t, post.Message, tt.expectedMsg) + }).Once().Return(&model.Post{}) + + p := getPluginTest(currentTestAPI, mockKvStore) + + _, err := p.ExecuteCommand(&plugin.Context{}, tt.commandArgs) + require.Nil(t, err) + + assert.Equal(t, true, isSendEphemeralPostCalled) + }) + } +} + +func TestGetMutedUsernames(t *testing.T) { + mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) + p := getPluginTest(mockAPI, mockKvStore) + userInfo, err := GetMockGHUserInfo(p) + assert.NoError(t, err) + + tests := []struct { + name string + setup func() + assertions func(t *testing.T, result []string) + }{ + { + name: "Error retrieving muted usernames", + setup: func() { + mockKvStore.EXPECT().Get("mockUserID-muted-users", gomock.Any()).Return(errors.New("error retrieving muted users")).Times(1) + }, + assertions: func(t *testing.T, result []string) { + assert.Nil(t, result) + }, + }, + { + name: "No muted usernames set for user", + setup: func() { + mockKvStore.EXPECT().Get("mockUserID-muted-users", gomock.Any()).DoAndReturn(func(key string, value *[]byte) error { + *value = []byte("") + return nil + }).Times(1) + }, + assertions: func(t *testing.T, result []string) { + assert.Equal(t, []string(nil), result) + }, + }, + { + name: "Successfully retrieves muted usernames", + setup: func() { + mutedUsernames := []byte("user1,user2,user3") + mockKvStore.EXPECT().Get("mockUserID-muted-users", gomock.Any()).DoAndReturn(func(key string, value *[]byte) error { + *value = mutedUsernames + return nil + }).Times(1) + }, + assertions: func(t *testing.T, result []string) { + assert.Equal(t, []string{"user1", "user2", "user3"}, result) + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.setup() + + mutedUsernames := p.getMutedUsernames(userInfo) + + tc.assertions(t, mutedUsernames) + }) + } +} + +func TestHandleMuteList(t *testing.T) { + mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) + p := getPluginTest(mockAPI, mockKvStore) + userInfo, err := GetMockGHUserInfo(p) + assert.NoError(t, err) + + tests := []struct { + name string + setup func() + assertions func(t *testing.T, result string) + }{ + { + name: "Error retrieving muted usernames", + setup: func() { + mockKvStore.EXPECT().Get("mockUserID-muted-users", gomock.Any()).Return(errors.New("error retrieving muted users")).Times(1) + }, + assertions: func(t *testing.T, result string) { + assert.Equal(t, "You have no muted users", result) + }, + }, + { + name: "No muted usernames set for user", + setup: func() { + mockKvStore.EXPECT().Get("mockUserID-muted-users", gomock.Any()).DoAndReturn(func(key string, value *[]byte) error { + *value = []byte("") + return nil + }).Times(1) + }, + assertions: func(t *testing.T, result string) { + assert.Equal(t, "You have no muted users", result) + }, + }, + { + name: "Successfully retrieves and formats muted usernames", + setup: func() { + mutedUsernames := []byte("user1,user2,user3") + mockKvStore.EXPECT().Get("mockUserID-muted-users", gomock.Any()).DoAndReturn(func(key string, value *[]byte) error { + *value = mutedUsernames + return nil + }).Times(1) + }, + assertions: func(t *testing.T, result string) { + expectedOutput := "Your muted users:\n- user1\n- user2\n- user3\n" + assert.Equal(t, expectedOutput, result) + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.setup() + + result := p.handleMuteList(nil, userInfo) + + tc.assertions(t, result) + }) + } +} + +func TestContains(t *testing.T) { + tests := []struct { + name string + slice []string + element string + assertions func(t *testing.T, result bool) + }{ + { + name: "Element is present in slice", + slice: []string{"expectedElement1", "expectedElement2", "expectedElement3"}, + element: "expectedElement2", + assertions: func(t *testing.T, result bool) { + assert.True(t, result) + }, + }, + { + name: "Element is not present in slice", + slice: []string{"expectedElement1", "expectedElement2", "expectedElement3"}, + element: "expectedElement4", + assertions: func(t *testing.T, result bool) { + assert.False(t, result) + }, + }, + { + name: "Empty slice", + slice: []string{}, + element: "expectedElement1", + assertions: func(t *testing.T, result bool) { + assert.False(t, result) + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := contains(tc.slice, tc.element) + tc.assertions(t, result) + }) + } +} + +func TestHandleMuteAdd(t *testing.T) { + mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) + p := getPluginTest(mockAPI, mockKvStore) + userInfo, err := GetMockGHUserInfo(p) + assert.NoError(t, err) + + tests := []struct { + name string + username string + setup func() + assertions func(t *testing.T, result string) + }{ + { + name: "Error saving the new muted username", + username: "errorUser", + setup: func() { + mockKvStore.EXPECT().Get(userInfo.UserID+"-muted-users", gomock.Any()).DoAndReturn(func(key string, value *[]byte) error { + *value = []byte("existingUser") + return nil + }).Times(1) + mockKvStore.EXPECT().Set(userInfo.UserID+"-muted-users", []byte("existingUser,errorUser")).Return(false, errors.New("store error")).Times(1) + }, + assertions: func(t *testing.T, result string) { + assert.Equal(t, "Error occurred saving list of muted users", result) + }, + }, + { + name: "Username is already muted", + username: "alreadyMutedUser", + setup: func() { + mockKvStore.EXPECT().Get(userInfo.UserID+"-muted-users", gomock.Any()).DoAndReturn(func(key string, value *[]byte) error { + *value = []byte("alreadyMutedUser") + return nil + }).Times(1) + }, + assertions: func(t *testing.T, result string) { + assert.Equal(t, "alreadyMutedUser is already muted", result) + }, + }, + { + name: "Invalid username with comma", + username: "invalid,user", + setup: func() { + mockKvStore.EXPECT().Get(userInfo.UserID+"-muted-users", gomock.Any()).DoAndReturn(func(key string, value *[]byte) error { + *value = []byte("") + return nil + }).Times(1) + }, + assertions: func(t *testing.T, result string) { + assert.Equal(t, "Invalid username provided", result) + }, + }, + { + name: "Successfully adds first muted username", + username: "firstUser", + setup: func() { + mockKvStore.EXPECT().Get(userInfo.UserID+"-muted-users", gomock.Any()).DoAndReturn(func(key string, value *[]byte) error { + *value = []byte("") + return nil + }).Times(1) + mockKvStore.EXPECT().Set(userInfo.UserID+"-muted-users", []byte("firstUser")).Return(true, nil).Times(1) + }, + assertions: func(t *testing.T, result string) { + expectedMessage := "`firstUser` is now muted. You'll no longer receive notifications for comments in your PRs and issues." + assert.Equal(t, expectedMessage, result) + }, + }, + { + name: "Successfully adds new muted username", + username: "newUser", + setup: func() { + mockKvStore.EXPECT().Get(userInfo.UserID+"-muted-users", gomock.Any()).DoAndReturn(func(key string, value *[]byte) error { + *value = []byte("existingUser") + return nil + }).Times(1) + mockKvStore.EXPECT().Set(userInfo.UserID+"-muted-users", []byte("existingUser,newUser")).Return(true, nil).Times(1) + }, + assertions: func(t *testing.T, result string) { + expectedMessage := "`newUser` is now muted. You'll no longer receive notifications for comments in your PRs and issues." + assert.Equal(t, expectedMessage, result) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.setup() + result := p.handleMuteAdd(nil, tc.username, userInfo) + tc.assertions(t, result) + }) + } +} + +func TestHandleUnmute(t *testing.T) { + mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) + p := getPluginTest(mockAPI, mockKvStore) + userInfo, err := GetMockGHUserInfo(p) + assert.NoError(t, err) + + tests := []struct { + name string + username string + setup func() + expectedResult string + }{ + { + name: "Error occurred while unmuting the user", + username: "user1", + setup: func() { + mutedUsernames := []byte("user1,user2,user3") + mockKvStore.EXPECT().Get("mockUserID-muted-users", gomock.Any()).DoAndReturn(func(key string, value *[]byte) error { + *value = mutedUsernames + return nil + }).Times(1) + mockKvStore.EXPECT().Set(userInfo.UserID+"-muted-users", gomock.Any()).Return(false, errors.New("error saving muted users")).Times(1) + }, + expectedResult: "Error occurred unmuting users", + }, + { + name: "Successfully unmute a user", + username: "user1", + setup: func() { + mutedUsernames := []byte("user1,user2,user3") + mockKvStore.EXPECT().Get("mockUserID-muted-users", gomock.Any()).DoAndReturn(func(key string, value *[]byte) error { + *value = mutedUsernames + return nil + }).Times(1) + mockKvStore.EXPECT().Set(userInfo.UserID+"-muted-users", gomock.Any()).Return(true, nil).Times(1) + }, + expectedResult: "`user1` is no longer muted", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.setup() + result := p.handleUnmute(nil, tc.username, userInfo) + assert.Equal(t, tc.expectedResult, result) + }) + } +} + +func TestHandleUnmuteAll(t *testing.T) { + mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) + p := getPluginTest(mockAPI, mockKvStore) + userInfo, err := GetMockGHUserInfo(p) + assert.NoError(t, err) + + tests := []struct { + name string + setup func() + assertions func(string) + expectedResult string + }{ + { + name: "Error occurred while unmuting all users", + setup: func() { + mockKvStore.EXPECT().Set(userInfo.UserID+"-muted-users", []byte("")).Return(false, errors.New("error saving muted users")).Times(1) + }, + assertions: func(expectedResult string) { + assert.Equal(t, expectedResult, "Error occurred unmuting users") + }, + }, + { + name: "Successfully unmute all users", + setup: func() { + mockKvStore.EXPECT().Set(userInfo.UserID+"-muted-users", []byte("")).Return(true, nil).Times(1) + }, + assertions: func(expectedResult string) { + assert.Equal(t, expectedResult, "Unmuted all users") + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.setup() + result := p.handleUnmuteAll(nil, userInfo) + tc.assertions(result) + }) + } +} + +func TestHandleMuteCommand(t *testing.T) { + mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) + p := getPluginTest(mockAPI, mockKvStore) + userInfo, err := GetMockGHUserInfo(p) + assert.NoError(t, err) + + tests := []struct { + name string + parameters []string + setup func() + assertions func(*testing.T, string) + }{ + { + name: "Success - list muted users", + parameters: []string{"list"}, + setup: func() { + mutedUsernames := []byte("user1,user2,user3") + mockKvStore.EXPECT().Get("mockUserID-muted-users", gomock.Any()).DoAndReturn(func(key string, value *[]byte) error { + *value = mutedUsernames + return nil + }).Times(1) + }, + assertions: func(t *testing.T, response string) { + assert.Equal(t, "Your muted users:\n- user1\n- user2\n- user3\n", response) + }, + }, + { + name: "Success - add new muted user", + parameters: []string{"add", "newUser"}, + setup: func() { + mockKvStore.EXPECT().Get(userInfo.UserID+"-muted-users", gomock.Any()).DoAndReturn(func(key string, value *[]byte) error { + *value = []byte("existingUser") + return nil + }).Times(1) + mockKvStore.EXPECT().Set(userInfo.UserID+"-muted-users", []byte("existingUser,newUser")).Return(true, nil).Times(1) + }, + assertions: func(t *testing.T, response string) { + assert.Equal(t, "`newUser` is now muted. You'll no longer receive notifications for comments in your PRs and issues.", response) + }, + }, + { + name: "Error - invalid number of parameters for add", + parameters: []string{"add"}, + setup: func() {}, + assertions: func(t *testing.T, response string) { + assert.Equal(t, "Invalid number of parameters supplied to add", response) + }, + }, + { + name: "Success - delete muted user", + parameters: []string{"delete", "user1"}, + setup: func() { + mutedUsernames := []byte("user1,user2,user3") + mockKvStore.EXPECT().Get("mockUserID-muted-users", gomock.Any()).DoAndReturn(func(key string, value *[]byte) error { + *value = mutedUsernames + return nil + }).Times(1) + mockKvStore.EXPECT().Set(userInfo.UserID+"-muted-users", gomock.Any()).Return(true, nil).Times(1) + }, + assertions: func(t *testing.T, response string) { + assert.Equal(t, "`user1` is no longer muted", response) + }, + }, + { + name: "Error - invalid number of parameters for delete", + parameters: []string{"delete"}, + setup: func() {}, + assertions: func(t *testing.T, response string) { + assert.Equal(t, "Invalid number of parameters supplied to delete", response) + }, + }, + { + name: "Success - delete all muted users", + parameters: []string{"delete-all"}, + setup: func() { + mockKvStore.EXPECT().Set(userInfo.UserID+"-muted-users", []byte("")).Return(true, nil).Times(1) + }, + assertions: func(t *testing.T, response string) { + assert.Equal(t, "Unmuted all users", response) + }, + }, + { + name: "Error - unknown subcommand", + parameters: []string{"unknown"}, + setup: func() {}, + assertions: func(t *testing.T, response string) { + assert.Equal(t, "Unknown subcommand unknown", response) + }, + }, + { + name: "Error - no parameters provided", + parameters: []string{}, + setup: func() {}, + assertions: func(t *testing.T, response string) { + assert.Equal(t, "Invalid mute command. Available commands are 'list', 'add' and 'delete'.", response) + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.setup() + result := p.handleMuteCommand(nil, nil, tc.parameters, userInfo) + tc.assertions(t, result) + }) + } +} + +func TestArrayDifference(t *testing.T) { + tests := []struct { + name string + a []string + b []string + expected []string + }{ + { + name: "No difference - all elements in a are in b", + a: []string{"apple", "banana", "cherry"}, + b: []string{"apple", "banana", "cherry"}, + expected: []string{}, + }, + { + name: "Difference - some elements in a are not in b", + a: []string{"apple", "banana", "cherry", "date"}, + b: []string{"apple", "banana"}, + expected: []string{"cherry", "date"}, + }, + { + name: "All elements different - no elements in a are in b", + a: []string{"apple", "banana"}, + b: []string{"cherry", "date"}, + expected: []string{"apple", "banana"}, + }, + { + name: "Empty a - no elements to compare", + a: []string{}, + b: []string{"apple", "banana"}, + expected: []string{}, + }, + { + name: "Empty b - all elements in a should be returned", + a: []string{"apple", "banana"}, + b: []string{}, + expected: []string{"apple", "banana"}, + }, + { + name: "Both a and b empty - no elements to compare", + a: []string{}, + b: []string{}, + expected: []string{}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := arrayDifference(tc.a, tc.b) + assert.ElementsMatch(t, tc.expected, result) + }) + } +} + +func TestHandleSubscriptionsList(t *testing.T) { + mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) + p := getPluginTest(mockAPI, mockKvStore) + + tests := []struct { + name string + channelID string + setup func() + assertions func(t *testing.T, result string) + }{ + { + name: "Error retrieving subscriptions", + channelID: "channel1", + setup: func() { + mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).Return(errors.New("store error")).Times(1) + }, + assertions: func(t *testing.T, result string) { + assert.Contains(t, result, "could not get subscriptions from KVStore: store error") + }, + }, + { + name: "No subscriptions in the channel", + channelID: "channel2", + setup: func() { + mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).DoAndReturn(func(key string, value **Subscriptions) error { + *value = &Subscriptions{Repositories: map[string][]*Subscription{}} + return nil + }).Times(1) + }, + assertions: func(t *testing.T, result string) { + assert.Equal(t, "Currently there are no subscriptions in this channel", result) + }, + }, + { + name: "Multiple subscriptions in the channel", + channelID: "channel3", + setup: func() { + mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).DoAndReturn(func(key string, value **Subscriptions) error { + *value = &Subscriptions{ + Repositories: map[string][]*Subscription{ + "repo1": { + { + ChannelID: "channel3", + Repository: "repo1", + }, + { + ChannelID: "channel4", + Repository: "repo1", + }, + }, + "repo2": { + { + ChannelID: "channel3", + Repository: "repo2", + }, + }, + }, + } + return nil + }).Times(1) + }, + assertions: func(t *testing.T, result string) { + expected := "### Subscriptions in this channel\n" + + "* `repo1` - \n" + + "* `repo2` - \n" + assert.Equal(t, expected, result) + }, + }, + { + name: "Subscriptions with flags", + channelID: "channel4", + setup: func() { + mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).DoAndReturn(func(key string, value **Subscriptions) error { + *value = &Subscriptions{ + Repositories: map[string][]*Subscription{ + "repo3": { + { + ChannelID: "channel4", + Repository: "repo3", + Flags: SubscriptionFlags{ + ExcludeOrgMembers: true, + RenderStyle: "compact", + ExcludeRepository: []string{"repoA", "repoB"}, + }, + }, + }, + }, + } + return nil + }).Times(1) + }, + assertions: func(t *testing.T, result string) { + expected := "### Subscriptions in this channel\n* `repo3` - --exclude-org-member true,--render-style compact,--exclude repoA,repoB\n" + assert.Equal(t, expected, result) + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.setup() + result := p.handleSubscriptionsList(nil, &model.CommandArgs{ChannelId: tc.channelID}, nil, nil) + tc.assertions(t, result) + }) + } +} + +func TestGetSubscribedFeatures(t *testing.T) { + mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) + p := getPluginTest(mockAPI, mockKvStore) + + tests := []struct { + name string + channelID string + owner string + repo string + setup func() + assertions func(t *testing.T, features Features, err error) + }{ + { + name: "Error retrieving subscriptions", + channelID: "channel1", + owner: "owner1", + repo: "repo1", + setup: func() { + mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).Return(errors.New("store error")).Times(1) + }, + assertions: func(t *testing.T, features Features, err error) { + assert.Error(t, err) + assert.ErrorContains(t, err, "store error") + assert.Empty(t, features) + }, + }, + { + name: "No subscriptions in the channel", + channelID: "channel2", + owner: "owner2", + repo: "repo2", + setup: func() { + mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).DoAndReturn(func(key string, value **Subscriptions) error { + *value = &Subscriptions{Repositories: map[string][]*Subscription{}} + return nil + }).Times(1) + }, + assertions: func(t *testing.T, features Features, err error) { + assert.NoError(t, err) + assert.Empty(t, features) + }, + }, + { + name: "Subscribed features found for repo", + channelID: "channel3", + owner: "owner3", + repo: "repo3", + setup: func() { + mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).DoAndReturn(func(key string, value **Subscriptions) error { + *value = &Subscriptions{ + Repositories: map[string][]*Subscription{ + "owner3/repo3": { + { + ChannelID: "channel3", + Repository: "owner3/repo3", + Features: Features("FeatureA"), + }, + }, + "owner4/repo4": { + { + ChannelID: "channel4", + Repository: "owner4/repo4", + Features: Features("FeatureB"), + }, + }, + }, + } + return nil + }).Times(1) + }, + assertions: func(t *testing.T, features Features, err error) { + assert.NoError(t, err) + expectedFeatures := Features("FeatureA") + assert.Equal(t, expectedFeatures, features) + }, + }, + { + name: "Subscribed features not found for repo", + channelID: "channel4", + owner: "owner5", + repo: "repo5", + setup: func() { + mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).DoAndReturn(func(key string, value **Subscriptions) error { + *value = &Subscriptions{ + Repositories: map[string][]*Subscription{ + "owner6/repo6": { + { + ChannelID: "channel4", + Repository: "owner6/repo6", + Features: Features("FeatureC"), + }, + }, + }, + } + return nil + }).Times(1) + }, + assertions: func(t *testing.T, features Features, err error) { + assert.NoError(t, err) + assert.Empty(t, features) + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.setup() + features, err := p.getSubscribedFeatures(tc.channelID, tc.owner, tc.repo) + tc.assertions(t, features, err) + }) + } +} + +func TestCreatePost(t *testing.T) { + mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) + p := getPluginTest(mockAPI, mockKvStore) + post := &model.Post{ + ChannelId: MockChannelID, + UserId: MockUserID, + Message: MockPostMessage, + } + + tests := []struct { + name string + setup func() + assertions func(t *testing.T, err error) + }{ + { + name: "Error creating a post", + setup: func() { + mockAPI.On("CreatePost", post).Return(nil, &model.AppError{Message: "error creating post"}).Times(1) + mockAPI.On("LogWarn", "Error while creating post", "post", post, "error", "error creating post").Times(1) + }, + assertions: func(t *testing.T, err error) { + assert.EqualError(t, err, "error creating post") + }, + }, + { + name: "Successfully create a post", + setup: func() { + mockAPI.On("CreatePost", post).Return(post, nil).Times(1) + }, + assertions: func(t *testing.T, err error) { + assert.NoError(t, err) + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.setup() + err := p.createPost(MockChannelID, MockUserID, MockPostMessage) + tc.assertions(t, err) + }) + } +} + +func TestHandleUnsubscribe(t *testing.T) { + mockKVStore, mockAPI, _, _, _ := GetTestSetup(t) + p := getPluginTest(mockAPI, mockKVStore) + p.setConfiguration(&Configuration{}) + post := &model.Post{ + ChannelId: MockChannelID, + UserId: MockBotID, + } + + tests := []struct { + name string + parameters []string + setup func() + assertions func(result string) + }{ + { + name: "No repository specified", + parameters: []string{}, + setup: func() {}, + assertions: func(result string) { + assert.Equal(t, "Please specify a repository.", result) + }, + }, + { + name: "Invalid repository format", + parameters: []string{""}, + setup: func() { + }, + assertions: func(result string) { + assert.Equal(t, "invalid repository", result) + }, + }, + { + name: "Failed to unsubscribe", + parameters: []string{"owner/repo"}, + setup: func() { + mockKVStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).Return(errors.New("error occurred getting subscriptions")) + mockAPI.On("LogWarn", "Failed to unsubscribe", "repo", "repo", "error", "could not get subscriptions: could not get subscriptions from KVStore: error occurred getting subscriptions") + }, + assertions: func(result string) { + assert.Equal(t, "Encountered an error trying to unsubscribe. Please try again.", result) + }, + }, + { + name: "Error getting user details", + parameters: []string{"owner/repo"}, + setup: func() { + mockKVStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).DoAndReturn(func(key string, value **Subscriptions) error { + *value = &Subscriptions{Repositories: map[string][]*Subscription{ + "owner/repo": {{ChannelID: "dummyChannelID", CreatorID: MockCreatorID, Repository: "owner/repo"}}}} + return nil + }).Times(1) + mockAPI.On("GetUser", MockUserID).Return(nil, &model.AppError{Message: "error getting user"}).Times(1) + mockAPI.On("LogWarn", "Error while fetching user details", "error", "error getting user").Times(1) + }, + assertions: func(result string) { + assert.Equal(t, "error while fetching user details: error getting user", result) + }, + }, + { + name: "Error creating post of unsubscribe with no repo", + parameters: []string{"owner"}, + setup: func() { + mockKVStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).DoAndReturn(func(key string, value **Subscriptions) error { + *value = &Subscriptions{Repositories: map[string][]*Subscription{ + "owner": {{ChannelID: "dummyChannelID", CreatorID: MockCreatorID, Repository: ""}}}} + return nil + }).Times(1) + mockAPI.On("GetUser", MockUserID).Return(&model.User{Username: MockUsername}, nil).Times(1) + mockAPI.On("CreatePost", mock.Anything).Return(nil, &model.AppError{Message: "error creating post"}).Times(1) + post.Message = "@mockUsername unsubscribed this channel from [owner](https://github.com/owner)" + mockAPI.On("LogWarn", "Error while creating post", "post", post, "error", "error creating post").Times(1) + }, + assertions: func(result string) { + assert.Equal(t, "@mockUsername unsubscribed this channel from [owner](https://github.com/owner) error creating the public post: error creating post", result) + }, + }, + { + name: "Success unsubscribing with no repo", + parameters: []string{"owner"}, + setup: func() { + mockKVStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).DoAndReturn(func(key string, value **Subscriptions) error { + *value = &Subscriptions{Repositories: map[string][]*Subscription{ + "owner": {{ChannelID: "dummyChannelID", CreatorID: MockCreatorID, Repository: ""}}}} + return nil + }).Times(1) + mockAPI.On("GetUser", MockUserID).Return(&model.User{Username: MockUsername}, nil).Times(1) + mockAPI.On("CreatePost", mock.Anything).Return(post, nil).Times(1) + }, + assertions: func(result string) { + assert.Empty(t, result) + }, + }, + { + name: "Error creating post of unsubscribe with no repo", + parameters: []string{"owner/repo"}, + setup: func() { + mockKVStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).DoAndReturn(func(key string, value **Subscriptions) error { + *value = &Subscriptions{Repositories: map[string][]*Subscription{ + "owner/repo": {{ChannelID: "dummyChannelID", CreatorID: MockCreatorID, Repository: "owner/repo"}}}} + return nil + }).Times(1) + mockAPI.On("GetUser", MockUserID).Return(&model.User{Username: MockUsername}, nil).Times(1) + mockAPI.On("CreatePost", mock.Anything).Return(nil, &model.AppError{Message: "error creating post"}).Times(1) + post.Message = "@mockUsername unsubscribed this channel from [owner/repo](https://github.com/owner/repo)" + mockAPI.On("LogWarn", "Error while creating post", "post", post, "error", "error creating post").Times(1) + }, + assertions: func(result string) { + assert.Equal(t, "@mockUsername unsubscribed this channel from [owner/repo](https://github.com/owner/repo) error creating the public post: error creating post", result) + }, + }, + { + name: "Success unsubscribing with repo", + parameters: []string{"owner/repo"}, + setup: func() { + mockKVStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).DoAndReturn(func(key string, value **Subscriptions) error { + *value = &Subscriptions{Repositories: map[string][]*Subscription{ + "owner/repo": {{ChannelID: "dummyChannelID", CreatorID: MockCreatorID, Repository: "owner/repo"}}}} + return nil + }).Times(1) + mockAPI.On("GetUser", MockUserID).Return(&model.User{Username: MockUsername}, nil).Times(1) + mockAPI.On("CreatePost", mock.Anything).Return(post, nil).Times(1) + }, + assertions: func(result string) { + assert.Empty(t, result) + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.setup() + + args := &model.CommandArgs{ + UserId: MockUserID, + ChannelId: MockChannelID, + } + + result := p.handleUnsubscribe(nil, args, tc.parameters, nil) + + tc.assertions(result) + }) + } +} + +func TestHandleSettings(t *testing.T) { + mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) + p := getPluginTest(mockAPI, mockKvStore) + userInfo, err := GetMockGHUserInfo(p) + assert.NoError(t, err) + + tests := []struct { + name string + parameters []string + setup func() + assertions func(string) + expectedResult string + }{ + { + name: "Error: Not enough parameters", + parameters: []string{ + settingNotifications, + }, + setup: func() {}, + assertions: func(result string) { + assert.Equal(t, result, "Please specify both a setting and value. Use `/github help` for more usage information.") + }, + expectedResult: "Please specify both a setting and value. Use `/github help` for more usage information.", + }, + { + name: "Invalid setting value for notifications", + parameters: []string{ + settingNotifications, "invalid", + }, + setup: func() {}, + assertions: func(result string) { + assert.Equal(t, result, "Invalid value. Accepted values are: \"on\" or \"off\".") + }, + expectedResult: "Invalid value. Accepted values are: \"on\" or \"off\".", + }, + { + name: "Successfully enable notifications", + parameters: []string{ + settingNotifications, settingOn, + }, + setup: func() { + mockKvStore.EXPECT().Set(userInfo.GitHubUsername+githubUsernameKey, gomock.Any()).Return(true, nil).Times(1) + mockKvStore.EXPECT().Set(userInfo.UserID+githubTokenKey, gomock.Any()).Return(true, nil).Times(1) + }, + assertions: func(result string) { + assert.Equal(t, result, "Settings updated.") + }, + expectedResult: "Settings updated.", + }, + { + name: "Error enabling notifications", + parameters: []string{ + settingNotifications, settingOn, + }, + setup: func() { + mockKvStore.EXPECT().Set(userInfo.GitHubUsername+githubUsernameKey, gomock.Any()).Return(false, errors.New("error setting notification")).Times(1) + mockAPI.On("LogWarn", "Failed to store GitHub to userID mapping", "userID", "mockUserID", "GitHub username", "mockUsername", "error", "encountered error saving github username mapping: error setting notification").Times(1) + mockKvStore.EXPECT().Set(userInfo.UserID+githubTokenKey, gomock.Any()).Return(true, nil).Times(1) + }, + assertions: func(result string) { + assert.Equal(t, result, "Settings updated.") + }, + expectedResult: "Settings updated.", + }, + { + name: "Successfully disable notifications", + parameters: []string{ + settingNotifications, settingOff, + }, + setup: func() { + mockKvStore.EXPECT().Set(userInfo.GitHubUsername+githubUsernameKey, gomock.Any()).Return(true, nil).Times(1) + mockKvStore.EXPECT().Set(userInfo.UserID+githubTokenKey, gomock.Any()).Return(true, nil).Times(1) + mockKvStore.EXPECT().Delete(userInfo.GitHubUsername + githubUsernameKey).Return(nil).Times(1) + }, + assertions: func(result string) { + assert.Equal(t, result, "Settings updated.") + }, + expectedResult: "Settings updated.", + }, + { + name: "Error disabling notifications", + parameters: []string{ + settingNotifications, settingOff, + }, + setup: func() { + mockKvStore.EXPECT().Set(userInfo.GitHubUsername+githubUsernameKey, gomock.Any()).Return(true, nil).Times(1) + mockKvStore.EXPECT().Set(userInfo.UserID+githubTokenKey, gomock.Any()).Return(true, nil).Times(1) + mockKvStore.EXPECT().Delete(userInfo.GitHubUsername + githubUsernameKey).Return(errors.New("error setting notification")).Times(1) + mockAPI.On("LogWarn", "Failed to delete GitHub to userID mapping", "userID", "mockUserID", "GitHub username", "mockUsername", "error", "error setting notification").Times(1) + }, + assertions: func(result string) { + assert.Equal(t, result, "Settings updated.") + }, + expectedResult: "Settings updated.", + }, + { + name: "Successfully set reminders to on", + parameters: []string{ + settingReminders, settingOn, + }, + setup: func() { + mockKvStore.EXPECT().Set(userInfo.UserID+githubTokenKey, gomock.Any()).Return(true, nil).Times(1) + }, + assertions: func(result string) { + assert.Equal(t, result, "Settings updated.") + }, + expectedResult: "Settings updated.", + }, + { + name: "Successfully set reminders to off", + parameters: []string{ + settingReminders, settingOff, + }, + setup: func() { + mockKvStore.EXPECT().Set(userInfo.UserID+githubTokenKey, gomock.Any()).Return(true, nil).Times(1) + }, + assertions: func(result string) { + assert.Equal(t, result, "Settings updated.") + }, + expectedResult: "Settings updated.", + }, + { + name: "Successfully set reminders to on-change", + parameters: []string{ + settingReminders, settingOnChange, + }, + setup: func() { + mockKvStore.EXPECT().Set(userInfo.UserID+githubTokenKey, gomock.Any()).Return(true, nil).Times(1) + }, + assertions: func(result string) { + assert.Equal(t, result, "Settings updated.") + }, + expectedResult: "Settings updated.", + }, + { + name: "Invalid setting value for reminders", + parameters: []string{ + settingReminders, "invalid", + }, + setup: func() {}, + assertions: func(result string) { + assert.Equal(t, result, "Invalid value. Accepted values are: \"on\" or \"off\" or \"on-change\" .") + }, + expectedResult: "Invalid value. Accepted values are: \"on\" or \"off\" or \"on-change\" .", + }, + { + name: "Unknown setting", + parameters: []string{ + "unknownSetting", settingOn, + }, + setup: func() {}, + assertions: func(result string) { + assert.Equal(t, result, "Unknown setting unknownSetting") + }, + expectedResult: "Unknown setting unknownSetting", + }, + { + name: "Error while storing settings", + parameters: []string{ + settingReminders, settingOnChange, + }, + setup: func() { + mockKvStore.EXPECT().Set(userInfo.UserID+githubTokenKey, gomock.Any()).Return(false, errors.New("error storing user info")).Times(1) + mockAPI.On("LogWarn", "Failed to store github user info", "error", "error occurred while trying to store user info into KV store: error storing user info").Times(1) + }, + assertions: func(result string) { + assert.Equal(t, result, "Failed to store settings") + }, + expectedResult: "Failed to store settings", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.setup() + + result := p.handleSettings(nil, nil, tc.parameters, userInfo) + + tc.assertions(result) + }) + } +} + +func TestHandleIssue(t *testing.T) { + mockClient, mockAPI, _, _, _ := GetTestSetup(t) + p := getPluginTest(mockAPI, mockClient) + userInfo, err := GetMockGHUserInfo(p) + assert.NoError(t, err) + + tests := []struct { + name string + parameters []string + setup func() + assertions func(result string) + }{ + { + name: "Invalid command: no parameters", + parameters: []string{}, + setup: func() {}, + assertions: func(result string) { + assert.Equal(t, "Invalid issue command. Available command is 'create'.", result) + }, + }, + { + name: "Unknown subcommand", + parameters: []string{"delete"}, + setup: func() {}, + assertions: func(result string) { + assert.Equal(t, "Unknown subcommand delete", result) + }, + }, + { + name: "Create issue with title", + parameters: []string{"create", "Test issue title"}, + setup: func() { + mockAPI.On("PublishWebSocketEvent", wsEventCreateIssue, + map[string]interface{}{ + "title": "Test issue title", + "channel_id": "testChannelID", + }, + &model.WebsocketBroadcast{UserId: "testUserID"}, + ).Return(nil).Once() + }, + assertions: func(result string) { + assert.Equal(t, "", result) + mockAPI.AssertCalled(t, "PublishWebSocketEvent", wsEventCreateIssue, + map[string]interface{}{ + "title": "Test issue title", + "channel_id": "testChannelID", + }, + &model.WebsocketBroadcast{UserId: "testUserID"}, + ) + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.setup() + + args := &model.CommandArgs{ + UserId: "testUserID", + ChannelId: "testChannelID", + } + + result := p.handleIssue(nil, args, tc.parameters, userInfo) + + tc.assertions(result) + }) + } +} + +func TestIsAuthorizedSysAdmin(t *testing.T) { + mockClient, mockAPI, _, _, _ := GetTestSetup(t) + p := getPluginTest(mockAPI, mockClient) + + tests := []struct { + name string + setup func() + assertions func(bool, error) + }{ + { + name: "Error getting user", + setup: func() { + mockAPI.On("GetUser", MockUserID).Return(nil, &model.AppError{Message: "error getting user"}).Times(1) + }, + assertions: func(result bool, err error) { + assert.False(t, result) + assert.EqualError(t, err, "error getting user") + }, + }, + { + name: "User is not a system admin", + setup: func() { + mockAPI.On("GetUser", MockUserID).Return(&model.User{Roles: "user"}, nil).Times(1) + }, + assertions: func(result bool, err error) { + assert.NoError(t, err) + assert.False(t, result) + }, + }, + { + name: "Successfully authorized as system admin", + setup: func() { + mockAPI.On("GetUser", MockUserID).Return(&model.User{Roles: "system_admin"}, nil).Times(1) + }, + assertions: func(result bool, err error) { + assert.NoError(t, err) + assert.True(t, result) + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.setup() + + result, err := p.isAuthorizedSysAdmin(MockUserID) + + tc.assertions(result, err) + }) + } +} + +func TestSliceContainsString(t *testing.T) { + tests := []struct { + name string + slice []string + searchString string + expectedResult bool + }{ + { + name: "Empty slice", + slice: []string{}, + searchString: "testString1", + expectedResult: false, + }, + { + name: "String exists in slice", + slice: []string{"testString1", "testString2", "testString3"}, + searchString: "testString2", + expectedResult: true, + }, + { + name: "String does not exist in slice", + slice: []string{"testString1", "testString2", "testString3"}, + searchString: "testString4", + expectedResult: false, + }, + { + name: "String is the first element in the slice", + slice: []string{"testString2", "testString1", "testString3"}, + searchString: "testString1", + expectedResult: true, + }, + { + name: "String is the last element in the slice", + slice: []string{"testString1", "testString3", "testString2"}, + searchString: "testString2", + expectedResult: true, + }, + { + name: "String with different case", + slice: []string{"testString1", "testString2", "TestString3"}, + searchString: "testString3", + expectedResult: false, + }, + { + name: "Search string is empty", + slice: []string{"testString1", "testString2", "testString3"}, + searchString: "", + expectedResult: false, + }, + { + name: "Slice contains empty string", + slice: []string{"testString1", "testString2", ""}, + searchString: "", + expectedResult: true, + }, + { + name: "Slice with multiple occurrences of the search string", + slice: []string{"testString2", "testString1", "testString2", "testString3"}, + searchString: "testString2", + expectedResult: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := SliceContainsString(tc.slice, tc.searchString) + assert.Equal(t, tc.expectedResult, result) + }) + } +} diff --git a/server/plugin/plugin.go b/server/plugin/plugin.go index 8d53787ae..7bc225ed8 100644 --- a/server/plugin/plugin.go +++ b/server/plugin/plugin.go @@ -64,7 +64,7 @@ var ( testOAuthServerURL = "" ) -type kvStore interface { +type KvStore interface { Set(key string, value any, options ...pluginapi.KVSetOption) (bool, error) ListKeys(page int, count int, options ...pluginapi.ListKeysOption) ([]string, error) Get(key string, o any) error @@ -75,7 +75,7 @@ type Plugin struct { plugin.MattermostPlugin client *pluginapi.Client - store kvStore + store KvStore // configurationLock synchronizes access to the configuration. configurationLock sync.RWMutex @@ -170,7 +170,7 @@ func (p *Plugin) GetGitHubClient(ctx context.Context, userID string) (*github.Cl return p.githubConnectUser(ctx, userInfo), nil } -func (p *Plugin) githubConnectUser(ctx context.Context, info *GitHubUserInfo) *github.Client { +func (p *Plugin) githubConnectUser(_ context.Context, info *GitHubUserInfo) *github.Client { tok := *info.Token return p.githubConnectToken(tok) } diff --git a/server/plugin/test_utils.go b/server/plugin/test_utils.go new file mode 100644 index 000000000..231851203 --- /dev/null +++ b/server/plugin/test_utils.go @@ -0,0 +1,551 @@ +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" + "golang.org/x/oauth2" + + "github.com/mattermost/mattermost-plugin-github/server/mocks" + + "github.com/mattermost/mattermost/server/public/plugin/plugintest" +) + +const ( + 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 { + Username string `json:"username"` +} + +func GetMockGHUserInfo(p *Plugin) (*GitHubUserInfo, error) { + encryptionKey := "dummyEncryptKey1" + p.setConfiguration(&Configuration{EncryptionKey: encryptionKey}) + encryptedToken, err := encrypt([]byte(encryptionKey), MockAccessToken) + if err != nil { + return nil, err + } + gitHubUserInfo := &GitHubUserInfo{ + UserID: MockUserID, + GitHubUsername: MockUsername, + Token: &oauth2.Token{AccessToken: encryptedToken}, + Settings: &UserSettings{}, + } + + return gitHubUserInfo, nil +} + +func GetTestSetup(t *testing.T) (*mocks.MockKvStore, *plugintest.API, *mocks.MockLogger, *mocks.MockLogger, *Context) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + mockKvStore := mocks.NewMockKvStore(mockCtrl) + mockAPI := &plugintest.API{} + mockLogger := mocks.NewMockLogger(mockCtrl) + mockLoggerWith := mocks.NewMockLogger(mockCtrl) + mockContext := GetMockContext(mockLogger) + + return mockKvStore, mockAPI, mockLogger, mockLoggerWith, &mockContext +} + +func GetMockContext(mockLogger *mocks.MockLogger) Context { + ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) + defer cancel() + + return Context{ + Ctx: ctx, + UserID: MockUserID, + Log: mockLogger, + } +} + +func GetMockUserContext(p *Plugin, mockLogger *mocks.MockLogger) (*UserContext, error) { + mockGHUserInfo, err := GetMockGHUserInfo(p) + if err != nil { + return nil, err + } + + mockUserContext := &UserContext{ + GetMockContext(mockLogger), + mockGHUserInfo, + } + + 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), + Head: github.String(MockHead), + Repo: &github.PushEventRepository{ + Name: github.String(MockRepoName), + FullName: github.String(MockOrgRepo), + Private: github.Bool(false), + HTMLURL: github.String(fmt.Sprintf("%s/%s", GithubBaseURL, MockOrgRepo)), + }, + Ref: github.String(MockEventReference), + Compare: github.String("%s%s/compare/old...new"), + Sender: &github.User{ + Login: github.String(MockUserLogin), + }, + Commits: []*github.HeadCommit{ + { + ID: github.String("abcdef123456"), + URL: github.String(fmt.Sprintf("%s%s/commit/abcdef123456", GithubBaseURL, MockOrgRepo)), + Message: github.String("Initial commit"), + Author: &github.CommitAuthor{ + Name: github.String("John Doe"), + }, + }, + { + ID: github.String("123456abcdef"), + URL: github.String(fmt.Sprintf("%s%s/commit/123456abcdef", GithubBaseURL, MockOrgRepo)), + Message: github.String("Update README"), + Author: &github.CommitAuthor{ + Name: github.String("Jane Smith"), + }, + }, + }, + } +} + +func GetMockPushEventWithoutCommit() *github.PushEvent { + return &github.PushEvent{ + PushID: github.Int64(1), + Head: github.String(MockHead), + Repo: &github.PushEventRepository{ + Name: github.String(MockRepoName), + FullName: github.String(MockOrgRepo), + Private: github.Bool(false), + HTMLURL: github.String(fmt.Sprintf("%s%s", GithubBaseURL, MockOrgRepo)), + }, + Ref: github.String(MockEventReference), + Compare: github.String(fmt.Sprintf("%s%s/compare/old...new", GithubBaseURL, MockOrgRepo)), + Sender: &github.User{ + Login: github.String(MockUserLogin), + }, + } +} + +func GetMockSubscriptions() *Subscriptions { + return &Subscriptions{ + Repositories: map[string][]*Subscription{ + "mockorg/mockrepo": { + { + ChannelID: "channel1", + CreatorID: "user1", + Features: Features("pushes"), + Flags: SubscriptionFlags{}, + Repository: MockOrgRepo, + }, + { + ChannelID: "channel2", + CreatorID: "user2", + Features: Features("creates"), + Flags: SubscriptionFlags{}, + Repository: MockOrgRepo, + }, + { + ChannelID: "channel2", + CreatorID: "user3", + Features: Features("deletes"), + Flags: SubscriptionFlags{}, + Repository: MockOrgRepo, + }, + { + ChannelID: "channel4", + CreatorID: "user4", + Features: Features("issue_comments"), + Flags: SubscriptionFlags{}, + Repository: MockOrgRepo, + }, + { + ChannelID: "channel5", + CreatorID: "user5", + Features: Features("pull_reviews"), + Flags: SubscriptionFlags{}, + Repository: MockOrgRepo, + }, + }, + }, + } +} + +func GetMockCreateEvent() *github.CreateEvent { + return &github.CreateEvent{ + Ref: github.String("v1.0.0"), + RefType: github.String("tag"), + Repo: &github.Repository{ + Name: github.String(MockRepoName), + FullName: github.String(MockOrgRepo), + Private: github.Bool(false), + HTMLURL: github.String(fmt.Sprintf("%s%s", GithubBaseURL, MockOrgRepo)), + }, + Sender: &github.User{ + Login: github.String(MockUserLogin), + }, + } +} + +func GetMockCreateEventWithUnsupportedRefType() *github.CreateEvent { + return &github.CreateEvent{ + Ref: github.String("feature/new-feature"), + RefType: github.String("unsupported"), + Repo: &github.Repository{ + Name: github.String(MockRepoName), + FullName: github.String(MockOrgRepo), + Private: github.Bool(false), + HTMLURL: github.String(fmt.Sprintf("%s%s", GithubBaseURL, MockOrgRepo)), + }, + Sender: &github.User{ + Login: github.String(MockUserLogin), + }, + } +} + +func GetMockDeleteEvent() *github.DeleteEvent { + return &github.DeleteEvent{ + Ref: github.String(MockBranch), + RefType: github.String("branch"), + Repo: &github.Repository{ + Name: github.String(MockRepoName), + FullName: github.String(MockOrgRepo), + Private: github.Bool(false), + HTMLURL: github.String(fmt.Sprintf("%s%s", GithubBaseURL, MockOrgRepo)), + }, + Sender: &github.User{ + Login: github.String(MockUserLogin), + }, + } +} + +func GetMockDeleteEventWithInvalidType() *github.DeleteEvent { + return &github.DeleteEvent{ + Ref: github.String(MockBranch), + RefType: github.String("invalidType"), + Repo: &github.Repository{ + Name: github.String(MockRepoName), + FullName: github.String(MockOrgRepo), + Private: github.Bool(false), + HTMLURL: github.String(fmt.Sprintf("%s%s", GithubBaseURL, MockOrgRepo)), + }, + Sender: &github.User{ + Login: github.String(MockUserLogin), + }, + } +} + +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(repo), + FullName: github.String(MockOrgRepo), + 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), + }, + PullRequest: &github.PullRequest{ + User: &github.User{Login: github.String(author)}, + }, + } +} + +func GetMockPullRequestReviewCommentEvent() *github.PullRequestReviewCommentEvent { + return &github.PullRequestReviewCommentEvent{ + Repo: &github.Repository{ + Name: github.String(MockRepoName), + FullName: github.String(MockOrgRepo), + Private: github.Bool(false), + HTMLURL: github.String(fmt.Sprintf("%s%s", GithubBaseURL, MockOrgRepo)), + }, + Comment: &github.PullRequestComment{ + ID: github.Int64(12345), + Body: github.String("This is a review comment"), + HTMLURL: github.String(fmt.Sprintf("%s%s/pull/1#discussion_r12345", GithubBaseURL, MockOrgRepo)), + }, + Sender: &github.User{ + Login: github.String(MockUserLogin), + }, + PullRequest: &github.PullRequest{}, + } +} + +func GetMockIssueCommentEvent(action, body, sender string) *github.IssueCommentEvent { + return &github.IssueCommentEvent{ + Action: github.String(action), + Repo: &github.Repository{ + Name: github.String(MockRepo), + FullName: github.String(MockOrgRepo), + Private: github.Bool(false), + }, + Comment: &github.IssueComment{ + Body: github.String(body), + }, + Issue: &github.Issue{ + User: &github.User{Login: github.String(MockIssueAuthor)}, + Assignees: []*github.User{{Login: github.String("assigneeUser")}}, + }, + Sender: &github.User{ + Login: github.String(sender), + }, + } +} + +func GetMockIssueCommentEventWithURL(action, body, sender, url string) *github.IssueCommentEvent { + event := GetMockIssueCommentEvent(action, body, sender) + event.Issue.HTMLURL = github.String(url) + return event +} + +func GetMockIssueCommentEventWithAssignees(eventType, action, body, sender string, assignees []string) *github.IssueCommentEvent { + assigneeUsers := make([]*github.User, len(assignees)) + for i, assignee := range assignees { + assigneeUsers[i] = &github.User{Login: github.String(assignee)} + } + + return &github.IssueCommentEvent{ + Action: github.String(action), + Repo: &github.Repository{ + Name: github.String(MockRepo), + FullName: github.String(MockOrgRepo), + Private: github.Bool(false), + }, + Comment: &github.IssueComment{ + Body: github.String(body), + }, + Issue: &github.Issue{ + User: &github.User{Login: github.String(MockIssueAuthor)}, + Assignees: assigneeUsers, + HTMLURL: github.String(fmt.Sprintf("%s%s/%s/123", GithubBaseURL, MockOrgRepo, eventType)), + }, + Sender: &github.User{ + Login: github.String(sender), + }, + } +} + +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)), + Private: github.Bool(isPrivate), + }, + PullRequest: &github.PullRequest{ + User: &github.User{Login: github.String(user)}, + 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), + }, + 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 new file mode 100644 index 000000000..d5eea10fd --- /dev/null +++ b/server/plugin/webhook_test.go @@ -0,0 +1,2372 @@ +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" + "github.com/mattermost/mattermost/server/public/model" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "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: "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 subscriptions found", + event: GetMockIssueCommentEvent(actionCreated, "mockBody", "mockUser"), + setup: func() { + mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).Return(nil).Times(1) + }, + }, + { + 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: "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 { + *v = GetMockSubscriptions() + } + return nil + }).Times(1) + mockAPI.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, nil).Times(1) + }, + }, + { + 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 { + *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.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", + 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 + event *github.PullRequestReviewEvent + setup func() + }{ + { + 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 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 { + *v = GetMockSubscriptions() + } + return nil + }).Times(1) + }, + }, + { + 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 { + *v = GetMockSubscriptions() + } + return nil + }).Times(1) + mockAPI.On("LogDebug", "Unhandled review state", "state", "canceled").Times(1) + }, + }, + { + 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 { + *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.postPullRequestReviewEvent(tc.event) + + mockAPI.AssertExpectations(t) + }) + } +} + +func TestPostPullRequestReviewCommentEvent(t *testing.T) { + mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) + p := getPluginTest(mockAPI, mockKvStore) + + tests := []struct { + name string + event *github.PullRequestReviewCommentEvent + setup func() + }{ + { + name: "no subscriptions found", + event: GetMockPullRequestReviewCommentEvent(), + setup: func() { + mockKvStore.EXPECT().Get(SubscriptionsKey, gomock.Any()).Return(nil).Times(1) + }, + }, + { + 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 { + *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 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 { + *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.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: "error creating post", + 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(&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) + 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.handleCommentMentionNotification(tc.event) + + mockAPI.AssertExpectations(t) + }) + } +} + +func TestHandleCommentAuthorNotification(t *testing.T) { + mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) + p := getPluginTest(mockAPI, mockKvStore) + + tests := []struct { + name string + event *github.IssueCommentEvent + setup func() + }{ + { + name: "author is the commenter", + event: GetMockIssueCommentEvent(actionCreated, "mockBody", "issueAuthor"), + setup: func() {}, + }, + { + name: "unsupported action", + event: GetMockIssueCommentEvent(actionEdited, "mockBody", "mockUser"), + 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"), + 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) + }, + }, + { + name: "unhandled issue type", + event: GetMockIssueCommentEventWithURL(actionCreated, "mockBody", "mockUser", "https://mockurl.com/unhandledType/123"), + 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) + 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 { + 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.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: "successful notification", + 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 { + 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("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) + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.setup() + + p.handleCommentAuthorNotification(tc.event) + + mockAPI.AssertExpectations(t) + }) + } +} + +func TestHandleCommentAssigneeNotification(t *testing.T) { + mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) + p := getPluginTest(mockAPI, mockKvStore) + + tests := []struct { + name string + event *github.IssueCommentEvent + setup func() + }{ + { + name: "unsupported issue type", + event: GetMockIssueCommentEventWithAssignees("mockType", actionCreated, "mockBody", "mockUser", []string{"assigneeUser"}), + setup: func() { + mockAPI.On("LogDebug", "Unhandled issue type", "Type", "mockType") + }, + }, + { + name: "assignee is the author", + event: GetMockIssueCommentEventWithAssignees("issues", actionCreated, "mockBody", "assigneeUser", []string{"assigneeUser"}), + setup: func() { + mockKvStore.EXPECT().Get("assigneeUser_githubusername", gomock.Any()).Return(nil).Times(1) + }, + }, + { + name: "issue author is assignee", + event: GetMockIssueCommentEventWithAssignees("issues", actionCreated, "mockBody", "assigneeUser", []string{"issueAuthor"}), + 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") + } + return nil + }).Times(1) + }, + }, + { + name: "assignee is the sender", + event: GetMockIssueCommentEventWithAssignees("issues", actionCreated, "mockBody", "mockUser", []string{"mockUser"}), + setup: func() { + 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) + mockKvStore.EXPECT().Get("assigneeUserID_githubtoken", gomock.Any()).Return(nil).Times(1) + }, + }, + { + name: "no permission to the repo", + event: GetMockIssueCommentEventWithAssignees("issues", actionCreated, "mockBody", "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) + 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.handleCommentAssigneeNotification(tc.event) + + mockAPI.AssertExpectations(t) + }) + } +} + +func TestHandlePullRequestNotification(t *testing.T) { + mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) + p := getPluginTest(mockAPI, mockKvStore) + + tests := []struct { + name string + event *github.PullRequestEvent + setup func() + }{ + { + 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("requestedReviewer_githubusername", gomock.Any()).Return(nil).Times(1) + }, + }, + { + 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("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("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: "review requested with valid user ID", + event: GetMockPullRequestEvent("review_requested", "mockRepo", MockValidLabel, false, "senderUser", "requestedReviewer", ""), + 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") + } + return 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) + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.setup() + + p.handlePullRequestNotification(tc.event) + + mockAPI.AssertExpectations(t) + }) + } +} + +func TestHandleIssueNotification(t *testing.T) { + mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) + p := getPluginTest(mockAPI, mockKvStore) + + tests := []struct { + name string + event *github.IssuesEvent + setup func() + }{ + { + name: "issue closed by author", + event: GetMockIssuesEvent(actionClosed, MockRepo, false, "authorUser", "authorUser", ""), + 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: "issue reopened with no repo permission", + event: GetMockIssuesEvent(actionReopened, MockRepo, true, "authorUser", "senderUser", ""), + setup: func() { + mockKvStore.EXPECT().Get("authorUser_githubusername", gomock.Any()).Return(nil).Times(1) + }, + }, + { + 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("assigneeUser_githubusername", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(*[]byte); ok { + *v = []byte("assigneeUserID") + } + return nil + }).Times(1) + }, + }, + { + name: "issue assigned with no repo permission for assignee", + event: GetMockIssuesEvent(actionAssigned, MockRepo, true, "senderUser", "demoassigneeUser", "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) + }, + }, + { + name: "unhandled event action", + event: GetMockIssuesEvent("unsupported_action", MockRepo, false, "senderUser", "", ""), + setup: func() { + mockAPI.On("LogDebug", "Unhandled event action", "action", "unsupported_action").Return(nil).Times(1) + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.setup() + + p.handleIssueNotification(tc.event) + + mockAPI.AssertExpectations(t) + }) + } +} + +func TestHandlePullRequestReviewNotification(t *testing.T) { + mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) + p := getPluginTest(mockAPI, mockKvStore) + + tests := []struct { + name string + event *github.PullRequestReviewEvent + setup func() + }{ + { + name: "review submitted by author", + event: GetMockPullRequestReviewEvent(actionSubmitted, "approved", MockRepo, false, "authorUser", "authorUser"), + setup: func() {}, + }, + { + name: "review action not submitted", + event: GetMockPullRequestReviewEvent("dismissed", "approved", MockRepo, false, "authorUser", "reviewerUser"), + setup: func() {}, + }, + { + name: "review with author not mapped to user ID", + event: GetMockPullRequestReviewEvent(actionSubmitted, "approved", MockRepo, false, "unknownAuthor", "reviewerUser"), + setup: func() { + mockKvStore.EXPECT().Get("reviewerUser_githubusername", gomock.Any()).Return(nil).Times(1) + }, + }, + { + name: "private repo, no permission for author", + event: GetMockPullRequestReviewEvent(actionSubmitted, "approved", MockRepo, true, "authorUser", "reviewerUser"), + setup: func() { + 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_githubtoken", gomock.Any()).DoAndReturn(func(key string, value interface{}) error { + if v, ok := value.(*[]byte); ok { + *v = []byte("authorUserID") + } + return nil + }).Times(1) + }, + }, + { + name: "successful review notification", + event: GetMockPullRequestReviewEvent(actionSubmitted, "approved", MockRepo, false, "authorUser", "reviewerUser"), + setup: func() { + 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("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.") + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.setup() + + p.handlePullRequestReviewNotification(tc.event) + + mockAPI.AssertExpectations(t) + }) + } +} + +func TestPostStarEvent(t *testing.T) { + mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) + p := getPluginTest(mockAPI, mockKvStore) + + tests := []struct { + name string + event *github.StarEvent + setup func() + }{ + { + name: "no subscribed channels for repository", + event: GetMockStarEvent(MockRepo, MockOrg, false, MockSender), + setup: func() { + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).Return(nil).Times(1) + }, + }, + { + name: "error creating post", + event: GetMockStarEvent(MockRepo, MockOrg, false, MockSender), + 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", 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: "successful star event notification", + event: GetMockStarEvent(MockRepo, MockOrg, false, MockSender), + 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", 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: "no subscribed channels for repository", + event: GetMockReleaseEvent(MockRepo, MockOrg, "created", MockSender), + setup: func() { + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).Return(nil).Times(1) + }, + }, + { + 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("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) + 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 release event notification", + event: GetMockReleaseEvent(MockRepo, MockOrg, "created", MockSender), + 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", featureReleases) + } + 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.postReleaseEvent(tc.event) + + mockAPI.AssertExpectations(t) + }) + } +} + +func TestPostDiscussionEvent(t *testing.T) { + mockKvStore, mockAPI, _, _, _ := GetTestSetup(t) + p := getPluginTest(mockAPI, mockKvStore) + + tests := []struct { + name string + event *github.DiscussionEvent + setup func() + }{ + { + name: "no subscribed channels for repository", + event: GetMockDiscussionEvent(MockRepo, MockOrg, MockSender), + setup: func() { + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).Return(nil).Times(1) + }, + }, + { + name: "error creating discussion post", + event: GetMockDiscussionEvent(MockRepo, MockOrg, MockSender), + 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", featureDiscussions) + } + 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 creating discussion notification post", "Post", mock.AnythingOfType("*model.Post"), "Error", "error creating post") + }, + }, + { + name: "successful discussion notification", + event: GetMockDiscussionEvent(MockRepo, MockOrg, MockSender), + 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", 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: "no subscribed channels for repository", + event: GetMockDiscussionCommentEvent(MockRepo, MockOrg, "created", MockSender), + setup: func() { + mockKvStore.EXPECT().Get("subscriptions", gomock.Any()).Return(nil).Times(1) + }, + }, + { + name: "unsupported action", + event: GetMockDiscussionCommentEvent(MockRepo, MockOrg, "edited", MockSender), + 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", featureDiscussionComments) + } + return nil + }).Times(1) + }, + }, + { + name: "error creating discussion comment post", + event: GetMockDiscussionCommentEvent(MockRepo, MockOrg, "created", MockSender), + 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", featureDiscussionComments) + } + 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 creating discussion comment post", "Post", mock.AnythingOfType("*model.Post"), "Error", "error creating post") + }, + }, + { + name: "successful discussion comment notification", + event: GetMockDiscussionCommentEvent(MockRepo, MockOrg, "created", MockSender), + 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", featureDiscussionComments) + } + 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.postDiscussionCommentEvent(tc.event) + + mockAPI.AssertExpectations(t) + }) + } +}