//go:build (linux || darwin) && !race // +build linux darwin // +build !race package plugin import ( "errors" "fmt" "net/http/httptest" "os" "os/exec" "path" "testing" "time" "github.com/gin-gonic/gin" "github.com/gotify/server/v2/auth" "github.com/gotify/server/v2/model" "github.com/gotify/server/v2/plugin/compat" "github.com/gotify/server/v2/plugin/testing/mock" "github.com/gotify/server/v2/test" "github.com/gotify/server/v2/test/testdb" "github.com/jinzhu/gorm" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" ) const ( examplePluginPath = "github.com/gotify/server/v2/plugin/example/echo" mockPluginPath = mock.ModulePath danglingPluginPath = "github.com/gotify/server/v2/plugin/testing/removed" ) type ManagerSuite struct { suite.Suite db *testdb.Database manager *Manager e *gin.Engine msgReceiver chan MessageWithUserID tmpDir test.TmpDir } func (s *ManagerSuite) Notify(uid uint, message *model.MessageExternal) { s.msgReceiver <- MessageWithUserID{ Message: *message, UserID: uid, } } func (s *ManagerSuite) SetupSuite() { s.tmpDir = test.NewTmpDir("gotify_managersuite") test.WithWd(path.Join(test.GetProjectDir(), "./plugin/example/echo"), func(origWd string) { exec.Command("go", "get", "-d").Run() goBuildFlags := []string{"build", "-buildmode=plugin", "-o=" + s.tmpDir.Path("echo.so")} goBuildFlags = append(goBuildFlags, extraGoBuildFlags...) cmd := exec.Command("go", goBuildFlags...) cmd.Stderr = os.Stderr assert.Nil(s.T(), cmd.Run()) }) s.db = testdb.NewDBWithDefaultUser(s.T()) s.makeDanglingPluginConf(1) e := gin.New() manager, err := NewManager(s.db.GormDatabase, s.tmpDir.Path(), e.Group("/plugin/:id/custom/"), s) s.e = e assert.Nil(s.T(), err) p := new(mock.Plugin) assert.Nil(s.T(), manager.LoadPlugin(p)) assert.Nil(s.T(), manager.initializeSingleUserPlugin(compat.UserContext{ ID: 1, Admin: true, }, p)) s.manager = manager s.msgReceiver = make(chan MessageWithUserID) assert.Contains(s.T(), s.manager.plugins, examplePluginPath) if pluginConf, err := s.db.GetPluginConfByUserAndPath(1, examplePluginPath); assert.NoError(s.T(), err) { assert.NotNil(s.T(), pluginConf) } } func (s *ManagerSuite) TearDownSuite() { assert.Nil(s.T(), s.tmpDir.Clean()) } func (s *ManagerSuite) getConfForExamplePlugin(uid uint) *model.PluginConf { pluginConf, err := s.db.GetPluginConfByUserAndPath(uid, examplePluginPath) assert.NoError(s.T(), err) return pluginConf } func (s *ManagerSuite) getConfForMockPlugin(uid uint) *model.PluginConf { pluginConf, err := s.db.GetPluginConfByUserAndPath(uid, mockPluginPath) assert.NoError(s.T(), err) return pluginConf } func (s *ManagerSuite) getMockPluginInstance(uid uint) *mock.PluginInstance { pid := s.getConfForMockPlugin(uid).ID return s.manager.instances[pid].(*mock.PluginInstance) } func (s *ManagerSuite) makeDanglingPluginConf(uid uint) *model.PluginConf { conf := &model.PluginConf{ UserID: uid, ModulePath: danglingPluginPath, Token: auth.GeneratePluginToken(), Enabled: true, } s.db.CreatePluginConf(conf) return conf } func (s *ManagerSuite) TestWebhook_blockedIfDisabled() { conf := s.getConfForExamplePlugin(1) t := httptest.NewRequest("GET", fmt.Sprintf("/plugin/%d/custom/%s/echo", conf.ID, conf.Token), nil) r := httptest.NewRecorder() s.e.ServeHTTP(r, t) assert.Equal(s.T(), 400, r.Code) } func (s *ManagerSuite) TestWebhook_successIfEnabled() { conf := s.getConfForExamplePlugin(1) assert.Nil(s.T(), s.manager.SetPluginEnabled(conf.ID, true)) defer func() { assert.Nil(s.T(), s.manager.SetPluginEnabled(conf.ID, false)) }() assert.True(s.T(), s.getConfForExamplePlugin(1).Enabled) t := httptest.NewRequest("GET", fmt.Sprintf("/plugin/%d/custom/%s/echo", conf.ID, conf.Token), nil) r := httptest.NewRecorder() s.e.ServeHTTP(r, t) assert.Equal(s.T(), 200, r.Code) } func (s *ManagerSuite) TestInitializePlugin_noOpIfEmpty() { assert.Nil(s.T(), s.manager.loadPlugins("")) } func (s *ManagerSuite) TestInitializePlugin_directoryInvalid_expectError() { assert.Error(s.T(), s.manager.loadPlugins("<<")) } func (s *ManagerSuite) TestInitializePlugin_invalidPlugin_expectError() { assert.Error(s.T(), s.manager.loadPlugins(test.GetProjectDir())) } func (s *ManagerSuite) TestInitializePlugin_brokenPlugin_expectError() { tmpDir := test.NewTmpDir("gotify_testbrokenplugin") defer tmpDir.Clean() test.WithWd(path.Join(test.GetProjectDir(), "./plugin/testing/broken/nothing"), func(origWd string) { exec.Command("go", "get", "-d").Run() goBuildFlags := []string{"build", "-buildmode=plugin", "-o=" + tmpDir.Path("empty.so")} goBuildFlags = append(goBuildFlags, extraGoBuildFlags...) cmd := exec.Command("go", goBuildFlags...) cmd.Stderr = os.Stderr assert.Nil(s.T(), cmd.Run()) }) assert.Error(s.T(), s.manager.loadPlugins(tmpDir.Path())) } func (s *ManagerSuite) TestInitializePlugin_alreadyLoaded_expectError() { assert.Error(s.T(), s.manager.loadPlugins(s.tmpDir.Path())) } func (s *ManagerSuite) TestInitializePlugin_alreadyEnabledInConf_expectAutoEnable() { s.db.User(2) s.db.CreatePluginConf(&model.PluginConf{ UserID: 2, ModulePath: mockPluginPath, Token: "P1234", Enabled: true, }) assert.Nil(s.T(), s.manager.InitializeForUserID(2)) inst := s.getMockPluginInstance(2) assert.True(s.T(), inst.Enabled) } func (s *ManagerSuite) TestInitializePlugin_alreadyEnabledInConf_failedToLoadConfig_disableAutomatically() { s.db.User(3) s.db.CreatePluginConf(&model.PluginConf{ UserID: 3, ModulePath: mockPluginPath, Token: "Ptttt", Enabled: true, Config: []byte(`invalid: """`), }) assert.Nil(s.T(), s.manager.InitializeForUserID(3)) inst := s.getMockPluginInstance(3) assert.False(s.T(), inst.Enabled) } func (s *ManagerSuite) TestInitializePlugin_alreadyEnabled_cannotEnable_disabledAutomatically() { s.db.NewUserWithName(4, "enable_fail_2") mock.ReturnErrorOnEnableForUser(4, errors.New("test error")) s.db.CreatePluginConf(&model.PluginConf{ UserID: 4, ModulePath: mockPluginPath, Token: "P5478", Enabled: true, }) assert.Nil(s.T(), s.manager.InitializeForUserID(4)) inst := s.getMockPluginInstance(4) assert.False(s.T(), inst.Enabled) assert.False(s.T(), s.getConfForMockPlugin(4).Enabled) } func (s *ManagerSuite) TestInitializePlugin_userIDNotExist_expectError() { assert.Error(s.T(), s.manager.InitializeForUserID(99)) } func (s *ManagerSuite) TestSetPluginEnabled() { pid := s.getConfForMockPlugin(1).ID assert.Nil(s.T(), s.manager.SetPluginEnabled(pid, true)) assert.Error(s.T(), s.manager.SetPluginEnabled(pid, true)) assert.Nil(s.T(), s.manager.SetPluginEnabled(pid, false)) } func (s *ManagerSuite) TestSetPluginEnabled_EnableReturnsError_cannotEnable() { s.db.NewUserWithName(5, "enable_fail") errExpected := errors.New("test error") mock.ReturnErrorOnEnableForUser(5, errExpected) assert.Nil(s.T(), s.manager.InitializeForUserID(5)) pid := s.getConfForMockPlugin(5).ID assert.Error(s.T(), s.manager.SetPluginEnabled(pid, false)) assert.EqualError(s.T(), s.manager.SetPluginEnabled(pid, true), errExpected.Error()) assert.False(s.T(), s.getConfForMockPlugin(5).Enabled) } func (s *ManagerSuite) TestSetPluginEnabled_DisableReturnsError_cannotDisable() { s.db.NewUserWithName(6, "disable_fail") errExpected := errors.New("test error") mock.ReturnErrorOnDisableForUser(6, errExpected) assert.Nil(s.T(), s.manager.InitializeForUserID(6)) pid := s.getConfForMockPlugin(6).ID assert.Nil(s.T(), s.manager.SetPluginEnabled(pid, true)) assert.EqualError(s.T(), s.manager.SetPluginEnabled(pid, false), errExpected.Error()) assert.True(s.T(), s.getConfForMockPlugin(6).Enabled) } func (s *ManagerSuite) TestAddRemoveNewUser() { s.db.User(7) s.makeDanglingPluginConf(7) assert.Nil(s.T(), s.manager.InitializeForUserID(7)) pid := s.getConfForExamplePlugin(7).ID assert.True(s.T(), s.manager.HasInstance(pid)) assert.Nil(s.T(), s.manager.SetPluginEnabled(s.getConfForMockPlugin(7).ID, true)) assert.Nil(s.T(), s.manager.RemoveUser(7)) assert.False(s.T(), s.manager.HasInstance(pid)) } func (s *ManagerSuite) TestRemoveUser_DisableFail_cannotRemove() { s.manager.initializeForUser(*s.db.NewUserWithName(8, "disable_fail_2")) errExpected := errors.New("test error") mock.ReturnErrorOnDisableForUser(8, errExpected) s.manager.SetPluginEnabled(s.getConfForMockPlugin(8).ID, true) assert.EqualError(s.T(), s.manager.RemoveUser(8), errExpected.Error()) } func (s *ManagerSuite) TestRemoveUser_danglingConf_expectSuccess() { // make a dangling conf for this instance s.db.User(9) s.db.CreatePluginConf(&model.PluginConf{ ModulePath: mockPluginPath, Enabled: true, UserID: 9, Token: auth.GenerateNotExistingToken(auth.GeneratePluginToken, s.manager.pluginConfExists), }) s.db.CreatePluginConf(&model.PluginConf{ ModulePath: examplePluginPath, Enabled: true, UserID: 9, Token: auth.GenerateNotExistingToken(auth.GeneratePluginToken, s.manager.pluginConfExists), }) assert.Nil(s.T(), s.manager.RemoveUser(9)) } func (s *ManagerSuite) TestTriggerMessage() { inst := s.getMockPluginInstance(1) inst.TriggerMessage() select { case msg := <-s.msgReceiver: assert.Equal(s.T(), uint(1), msg.UserID) assert.NotEmpty(s.T(), msg.Message.Extras) case <-time.After(1 * time.Second): assert.Fail(s.T(), "read message time out") } } func (s *ManagerSuite) TestStorage() { inst := s.getMockPluginInstance(1) assert.Nil(s.T(), inst.SetStorage([]byte("test"))) storage, err := inst.GetStorage() assert.Nil(s.T(), err) assert.Equal(s.T(), "test", string(storage)) } func (s *ManagerSuite) TestGetPluginInfo() { assert.Equal(s.T(), mock.Name, s.manager.PluginInfo(mock.ModulePath).Name) } func (s *ManagerSuite) TestGetPluginInfo_notFound_doNotPanic() { assert.NotPanics(s.T(), func() { s.manager.PluginInfo("not/exist") }) } func (s *ManagerSuite) TestSetPluginEnabled_expectNotFound() { assert.Error(s.T(), s.manager.SetPluginEnabled(99, true)) } func TestManagerSuite(t *testing.T) { suite.Run(t, new(ManagerSuite)) } func TestNewManager_CannotLoadDirectory_expectError(t *testing.T) { _, err := NewManager(nil, "<>", nil, nil) assert.Error(t, err) } func TestNewManager_NonPluginFile_expectError(t *testing.T) { _, err := NewManager(nil, path.Join(test.GetProjectDir(), "test/assets/"), nil, nil) assert.Error(t, err) } func TestNewManager_FaultyDB_expectError(t *testing.T) { tmpDir := test.NewTmpDir("gotify_testnewmanager_faultydb") defer tmpDir.Clean() for _, data := range []struct { pkg string faultyTable string name string }{{"plugin/example/minimal/", "plugin_confs", "minimal"}, {"plugin/example/clock/", "applications", "clock"}} { test.WithWd(path.Join(test.GetProjectDir(), data.pkg), func(origWd string) { exec.Command("go", "get", "-d").Run() goBuildFlags := []string{"build", "-buildmode=plugin", "-o=" + tmpDir.Path(fmt.Sprintf("%s.so", data.name))} goBuildFlags = append(goBuildFlags, extraGoBuildFlags...) cmd := exec.Command("go", goBuildFlags...) cmd.Stderr = os.Stderr assert.Nil(t, cmd.Run()) }) db := testdb.NewDBWithDefaultUser(t) db.GormDatabase.DB.Callback().Create().Register("no_create", func(s *gorm.Scope) { if s.TableName() == data.faultyTable { s.Err(errors.New("database failed")) } }) _, err := NewManager(db, tmpDir.Path(), nil, nil) assert.Error(t, err) os.Remove(tmpDir.Path(fmt.Sprintf("%s.so", data.name))) } } func TestNewManager_InternalApplicationManagement(t *testing.T) { db := testdb.NewDBWithDefaultUser(t) { // Application exist, no plugin conf db.CreateApplication(&model.Application{ Token: "Ainternal_obsolete", Internal: true, Name: "obsolete plugin application", UserID: 1, }) if app, err := db.GetApplicationByToken("Ainternal_obsolete"); assert.NoError(t, err) { assert.True(t, app.Internal) } _, err := NewManager(db, "", nil, nil) assert.Nil(t, err) if app, err := db.GetApplicationByToken("Ainternal_obsolete"); assert.NoError(t, err) { assert.False(t, app.Internal) } } { // Application exist, conf exist, no compat assert.NoError(t, db.CreateApplication(&model.Application{ Token: "Ainternal_not_loaded", Internal: true, Name: "not loaded plugin application", UserID: 1, })) if app, err := db.GetApplicationByToken("Ainternal_not_loaded"); assert.NoError(t, err) { assert.NoError(t, db.CreatePluginConf(&model.PluginConf{ ApplicationID: app.ID, UserID: 1, Enabled: true, Token: auth.GeneratePluginToken(), })) } if app, err := db.GetApplicationByToken("Ainternal_not_loaded"); assert.NoError(t, err) { assert.True(t, app.Internal) } _, err := NewManager(db, "", nil, nil) assert.Nil(t, err) if app, err := db.GetApplicationByToken("Ainternal_not_loaded"); assert.NoError(t, err) { assert.False(t, app.Internal) } } { // Application exist, conf exist, has compat assert.NoError(t, db.CreateApplication(&model.Application{ Token: "Ainternal_loaded", Internal: false, Name: "not loaded plugin application", UserID: 1, })) if app, err := db.GetApplicationByToken("Ainternal_loaded"); assert.NoError(t, err) { assert.NoError(t, db.CreatePluginConf(&model.PluginConf{ ApplicationID: app.ID, UserID: 1, Enabled: true, ModulePath: mock.ModulePath, Token: auth.GeneratePluginToken(), })) } if app, err := db.GetApplicationByToken("Ainternal_loaded"); assert.NoError(t, err) { assert.False(t, app.Internal) } manager, err := NewManager(db, "", nil, nil) assert.Nil(t, err) assert.Nil(t, manager.LoadPlugin(new(mock.Plugin))) assert.Nil(t, manager.InitializeForUserID(1)) if app, err := db.GetApplicationByToken("Ainternal_loaded"); assert.NoError(t, err) { assert.True(t, app.Internal) } } } func TestPluginFileLoadError(t *testing.T) { err := pluginFileLoadError{Filename: "test.so", UnderlyingError: errors.New("test error")} assert.Error(t, err) assert.Contains(t, err.Error(), "test.so") assert.Contains(t, err.Error(), "test error") }