diff --git a/server/channels/app/file_test_coverage.go b/server/channels/app/file_test_coverage.go new file mode 100644 index 00000000000..fa7bfc6baa1 --- /dev/null +++ b/server/channels/app/file_test_coverage.go @@ -0,0 +1,635 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "bytes" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUploadFileX_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + t.Run("driver not configured", func(t *testing.T) { + oldDriverName := th.App.Config().FileSettings.DriverName + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.FileSettings.DriverName = model.NewPointer("") + }) + defer th.App.UpdateConfig(func(cfg *model.Config) { + cfg.FileSettings.DriverName = oldDriverName + }) + + _, appErr := th.App.UploadFileX( + th.Context, + th.BasicChannel.Id, + "test.txt", + strings.NewReader("test content"), + ) + require.NotNil(t, appErr) + assert.Equal(t, "api.file.upload_file.storage.app_error", appErr.Id) + assert.Equal(t, http.StatusNotImplemented, appErr.StatusCode) + }) + + t.Run("file too large via ContentLength", func(t *testing.T) { + oldMaxSize := th.App.Config().FileSettings.MaxFileSize + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.FileSettings.MaxFileSize = model.NewPointer(int64(10)) + }) + defer th.App.UpdateConfig(func(cfg *model.Config) { + cfg.FileSettings.MaxFileSize = oldMaxSize + }) + + _, appErr := th.App.UploadFileX( + th.Context, + th.BasicChannel.Id, + "test.txt", + strings.NewReader("test content that exceeds size limit"), + UploadFileSetContentLength(100), + ) + require.NotNil(t, appErr) + assert.Equal(t, "api.file.upload_file.too_large_detailed.app_error", appErr.Id) + assert.Equal(t, http.StatusRequestEntityTooLarge, appErr.StatusCode) + }) + + t.Run("file too large detected after write", func(t *testing.T) { + oldMaxSize := th.App.Config().FileSettings.MaxFileSize + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.FileSettings.MaxFileSize = model.NewPointer(int64(10)) + }) + defer th.App.UpdateConfig(func(cfg *model.Config) { + cfg.FileSettings.MaxFileSize = oldMaxSize + }) + + largeContent := strings.Repeat("a", 20) + _, appErr := th.App.UploadFileX( + th.Context, + th.BasicChannel.Id, + "test.txt", + strings.NewReader(largeContent), + ) + require.NotNil(t, appErr) + assert.Equal(t, "api.file.upload_file.too_large_detailed.app_error", appErr.Id) + assert.Equal(t, http.StatusRequestEntityTooLarge, appErr.StatusCode) + }) + + t.Run("image resolution limit exceeded", func(t *testing.T) { + oldMaxRes := th.App.Config().FileSettings.MaxImageResolution + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.FileSettings.MaxImageResolution = model.NewPointer(int64(10)) + }) + defer th.App.UpdateConfig(func(cfg *model.Config) { + cfg.FileSettings.MaxImageResolution = oldMaxRes + }) + + // Create a simple 1x1 PNG + pngData := []byte{ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk + 0x00, 0x00, 0x00, 0x64, 0x00, 0x00, 0x00, 0x64, // 100x100 dimensions + 0x08, 0x02, 0x00, 0x00, 0x00, 0x9A, 0x20, 0x6C, 0x5C, + } + + _, appErr := th.App.UploadFileX( + th.Context, + th.BasicChannel.Id, + "large.png", + bytes.NewReader(pngData), + ) + require.NotNil(t, appErr) + assert.Equal(t, "api.file.upload_file.large_image_detailed.app_error", appErr.Id) + assert.Equal(t, http.StatusBadRequest, appErr.StatusCode) + }) +} + +func TestGetFileInfo_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + t.Run("file not found", func(t *testing.T) { + nonExistentId := model.NewId() + _, appErr := th.App.GetFileInfo(th.Context, nonExistentId) + require.NotNil(t, appErr) + assert.Equal(t, "app.file_info.get.app_error", appErr.Id) + assert.Equal(t, http.StatusNotFound, appErr.StatusCode) + }) + + t.Run("invalid file id", func(t *testing.T) { + _, appErr := th.App.GetFileInfo(th.Context, "invalid-id") + require.NotNil(t, appErr) + assert.Equal(t, "app.file_info.get.app_error", appErr.Id) + }) +} + +func TestGetFileInfos_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + t.Run("invalid page parameters", func(t *testing.T) { + _, appErr := th.App.GetFileInfos(th.Context, -1, 10, nil) + require.NotNil(t, appErr) + assert.Equal(t, "app.file_info.get_with_options.app_error", appErr.Id) + assert.Equal(t, http.StatusBadRequest, appErr.StatusCode) + }) + + t.Run("per page limit exceeded", func(t *testing.T) { + _, appErr := th.App.GetFileInfos(th.Context, 0, 1001, nil) + require.NotNil(t, appErr) + assert.Equal(t, "app.file_info.get_with_options.app_error", appErr.Id) + assert.Equal(t, http.StatusBadRequest, appErr.StatusCode) + }) +} + +func TestGetFile_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + t.Run("file info not found", func(t *testing.T) { + _, appErr := th.App.GetFile(th.Context, model.NewId()) + require.NotNil(t, appErr) + assert.Equal(t, "app.file_info.get.app_error", appErr.Id) + assert.Equal(t, http.StatusNotFound, appErr.StatusCode) + }) + + t.Run("file exists in db but not in storage", func(t *testing.T) { + // Upload a file + data := []byte("test content") + info, appErr := th.App.UploadFile( + th.Context, + data, + th.BasicChannel.Id, + "test.txt", + ) + require.Nil(t, appErr) + defer th.App.Srv().Store().FileInfo().PermanentDelete(th.Context, info.Id) + + // Remove from storage but keep in db + appErr = th.App.RemoveFile(info.Path) + require.Nil(t, appErr) + + // Try to get the file + _, appErr = th.App.GetFile(th.Context, info.Id) + require.NotNil(t, appErr) + assert.Equal(t, "api.file.file_reader.app_error", appErr.Id) + assert.Equal(t, http.StatusInternalServerError, appErr.StatusCode) + }) +} + +func TestFileReader_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + t.Run("file not exists", func(t *testing.T) { + _, appErr := th.App.FileReader("nonexistent/path/file.txt") + require.NotNil(t, appErr) + assert.Equal(t, "api.file.file_reader.app_error", appErr.Id) + assert.Equal(t, http.StatusInternalServerError, appErr.StatusCode) + }) + + t.Run("invalid path", func(t *testing.T) { + _, appErr := th.App.FileReader("") + require.NotNil(t, appErr) + assert.Equal(t, "api.file.file_reader.app_error", appErr.Id) + }) +} + +func TestWriteFile_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + t.Run("write to invalid path", func(t *testing.T) { + reader := strings.NewReader("test content") + written, appErr := th.App.WriteFile(reader, "") + assert.Equal(t, int64(0), written) + require.NotNil(t, appErr) + assert.Equal(t, "api.file.write_file.app_error", appErr.Id) + assert.Equal(t, http.StatusInternalServerError, appErr.StatusCode) + }) +} + +func TestCopyFileInfos_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + t.Run("copy non-existent file", func(t *testing.T) { + nonExistentId := model.NewId() + _, appErr := th.App.CopyFileInfos(th.Context, th.BasicUser.Id, []string{nonExistentId}) + require.NotNil(t, appErr) + assert.Equal(t, "app.file_info.get.app_error", appErr.Id) + assert.Equal(t, http.StatusNotFound, appErr.StatusCode) + }) + + t.Run("copy with invalid file id", func(t *testing.T) { + _, appErr := th.App.CopyFileInfos(th.Context, th.BasicUser.Id, []string{"invalid-id"}) + require.NotNil(t, appErr) + assert.Equal(t, "app.file_info.get.app_error", appErr.Id) + }) +} + +func TestFileExists_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + t.Run("check empty path", func(t *testing.T) { + exists, appErr := th.App.FileExists("") + assert.False(t, exists) + require.NotNil(t, appErr) + assert.Equal(t, "api.file.file_exists.app_error", appErr.Id) + assert.Equal(t, http.StatusInternalServerError, appErr.StatusCode) + }) +} + +func TestFileSize_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + t.Run("get size of non-existent file", func(t *testing.T) { + size, appErr := th.App.FileSize("nonexistent/file.txt") + assert.Equal(t, int64(0), size) + require.NotNil(t, appErr) + assert.Equal(t, "api.file.file_size.app_error", appErr.Id) + assert.Equal(t, http.StatusInternalServerError, appErr.StatusCode) + }) +} + +func TestRemoveFile_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + t.Run("remove non-existent file", func(t *testing.T) { + appErr := th.App.RemoveFile("nonexistent/file.txt") + require.NotNil(t, appErr) + assert.Equal(t, "api.file.remove_file.app_error", appErr.Id) + assert.Equal(t, http.StatusInternalServerError, appErr.StatusCode) + }) + + t.Run("remove empty path", func(t *testing.T) { + appErr := th.App.RemoveFile("") + require.NotNil(t, appErr) + assert.Equal(t, "api.file.remove_file.app_error", appErr.Id) + }) +} + +func TestMoveFile_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + t.Run("move non-existent file", func(t *testing.T) { + appErr := th.App.MoveFile("old/path.txt", "new/path.txt") + require.NotNil(t, appErr) + assert.Equal(t, "api.file.move_file.app_error", appErr.Id) + assert.Equal(t, http.StatusInternalServerError, appErr.StatusCode) + }) + + t.Run("move to invalid destination", func(t *testing.T) { + // First create a file + data := []byte("test content") + info, appErr := th.App.UploadFile(th.Context, data, th.BasicChannel.Id, "test.txt") + require.Nil(t, appErr) + defer th.App.RemoveFile(info.Path) + defer th.App.Srv().Store().FileInfo().PermanentDelete(th.Context, info.Id) + + // Try to move to empty destination + appErr = th.App.MoveFile(info.Path, "") + require.NotNil(t, appErr) + assert.Equal(t, "api.file.move_file.app_error", appErr.Id) + }) +} + +func TestAppendFile_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + t.Run("append to non-existent file", func(t *testing.T) { + reader := strings.NewReader("append content") + written, appErr := th.App.AppendFile(reader, "nonexistent/file.txt") + assert.Equal(t, int64(0), written) + require.NotNil(t, appErr) + assert.Equal(t, "api.file.append_file.app_error", appErr.Id) + assert.Equal(t, http.StatusInternalServerError, appErr.StatusCode) + }) +} + +func TestListDirectory_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + t.Run("list non-existent directory", func(t *testing.T) { + paths, appErr := th.App.ListDirectory("nonexistent/directory") + assert.Empty(t, paths) + require.NotNil(t, appErr) + assert.Equal(t, "api.file.list_directory.app_error", appErr.Id) + assert.Equal(t, http.StatusInternalServerError, appErr.StatusCode) + }) +} + +func TestRemoveDirectory_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + t.Run("remove non-existent directory", func(t *testing.T) { + appErr := th.App.RemoveDirectory("nonexistent/directory") + require.NotNil(t, appErr) + assert.Equal(t, "api.file.remove_directory.app_error", appErr.Id) + assert.Equal(t, http.StatusInternalServerError, appErr.StatusCode) + }) +} + +func TestSetFileSearchableContent_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + t.Run("set content for non-existent file", func(t *testing.T) { + appErr := th.App.SetFileSearchableContent(th.Context, model.NewId(), "searchable content") + require.NotNil(t, appErr) + assert.Equal(t, "app.file_info.set_searchable_content.app_error", appErr.Id) + assert.Equal(t, http.StatusNotFound, appErr.StatusCode) + }) +} + +func TestCheckMandatoryS3Fields_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + t.Run("missing S3 bucket", func(t *testing.T) { + settings := &model.FileSettings{ + DriverName: model.NewPointer(model.ImageDriverS3), + AmazonS3AccessKeyId: model.NewPointer("test-key"), + AmazonS3SecretAccessKey: model.NewPointer("test-secret"), + AmazonS3Bucket: model.NewPointer(""), + AmazonS3PathPrefix: model.NewPointer(""), + AmazonS3Region: model.NewPointer("us-east-1"), + AmazonS3Endpoint: model.NewPointer(""), + AmazonS3SSL: model.NewPointer(true), + AmazonS3SignV2: model.NewPointer(false), + AmazonS3SSE: model.NewPointer(false), + AmazonS3Trace: model.NewPointer(false), + AmazonS3RequestTimeoutMilliseconds: model.NewPointer(int64(5000)), + } + + appErr := th.App.CheckMandatoryS3Fields(settings) + require.NotNil(t, appErr) + assert.Equal(t, "api.admin.test_s3.missing_s3_bucket", appErr.Id) + assert.Equal(t, http.StatusBadRequest, appErr.StatusCode) + }) +} + +func TestTestFileStoreConnectionWithConfig_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + t.Run("invalid S3 credentials", func(t *testing.T) { + settings := &model.FileSettings{ + DriverName: model.NewPointer(model.ImageDriverS3), + AmazonS3AccessKeyId: model.NewPointer("invalid-key"), + AmazonS3SecretAccessKey: model.NewPointer("invalid-secret"), + AmazonS3Bucket: model.NewPointer("test-bucket"), + AmazonS3PathPrefix: model.NewPointer(""), + AmazonS3Region: model.NewPointer("us-east-1"), + AmazonS3Endpoint: model.NewPointer("s3.amazonaws.com"), + AmazonS3SSL: model.NewPointer(true), + AmazonS3SignV2: model.NewPointer(false), + AmazonS3SSE: model.NewPointer(false), + AmazonS3Trace: model.NewPointer(false), + AmazonS3RequestTimeoutMilliseconds: model.NewPointer(int64(5000)), + } + + appErr := th.App.TestFileStoreConnectionWithConfig(settings) + require.NotNil(t, appErr) + // Could be auth error or connection error depending on network + assert.Contains(t, []string{"api.file.test_connection_s3_auth.app_error", "api.file.test_connection.app_error"}, appErr.Id) + assert.Equal(t, http.StatusInternalServerError, appErr.StatusCode) + }) +} + +func TestPermanentDeleteFilesByPost_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + t.Run("delete files for non-existent post", func(t *testing.T) { + // This should not error, just return gracefully + appErr := th.App.PermanentDeleteFilesByPost(th.Context, model.NewId()) + require.Nil(t, appErr) + }) + + t.Run("delete files when file in storage missing", func(t *testing.T) { + // Create a post with a file + data := []byte("test content") + info, appErr := th.App.UploadFile(th.Context, data, th.BasicChannel.Id, "test.txt") + require.Nil(t, appErr) + + // Create a post + post, _, appErr := th.App.CreatePost(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "test post", + FileIds: []string{info.Id}, + }, th.BasicChannel, model.CreatePostFlags{}) + require.Nil(t, appErr) + + // Remove file from storage but keep in db + appErr = th.App.RemoveFile(info.Path) + require.Nil(t, appErr) + + // Delete files by post - should handle missing file gracefully + appErr = th.App.PermanentDeleteFilesByPost(th.Context, post.Id) + require.Nil(t, appErr) + }) +} + +func TestUploadFileX_ImageProcessingErrors(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + t.Run("corrupted image data", func(t *testing.T) { + // Create corrupted PNG data + corruptedPNG := []byte{ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature + 0x00, 0x00, 0x00, 0x0D, 0xFF, 0xFF, 0xFF, 0xFF, // Corrupted IHDR + } + + info, appErr := th.App.UploadFileX( + th.Context, + th.BasicChannel.Id, + "corrupted.png", + bytes.NewReader(corruptedPNG), + ) + // Should still upload but without preview generation + require.Nil(t, appErr) + require.NotNil(t, info) + defer th.App.RemoveFile(info.Path) + defer th.App.Srv().Store().FileInfo().PermanentDelete(th.Context, info.Id) + }) + + t.Run("SVG with invalid content", func(t *testing.T) { + invalidSVG := []byte(``) + + info, appErr := th.App.UploadFileX( + th.Context, + th.BasicChannel.Id, + "invalid.svg", + bytes.NewReader(invalidSVG), + UploadFileSetRaw(), + ) + require.Nil(t, appErr) + require.NotNil(t, info) + assert.False(t, info.HasPreviewImage) + defer th.App.RemoveFile(info.Path) + defer th.App.Srv().Store().FileInfo().PermanentDelete(th.Context, info.Id) + }) +} + +func TestWriteZipFile_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + t.Run("write to failing writer", func(t *testing.T) { + fileDatas := []model.FileData{ + { + Filename: "test.txt", + Body: []byte("test content"), + }, + } + + // Use a writer that fails + failWriter := &failingWriter{failAfter: 10} + err := th.App.WriteZipFile(failWriter, fileDatas) + require.NotNil(t, err) + assert.Contains(t, err.Error(), "write failed") + }) +} + +// failingWriter is a writer that fails after writing a certain number of bytes +type failingWriter struct { + written int + failAfter int +} + +func (w *failingWriter) Write(p []byte) (n int, err error) { + if w.written+len(p) > w.failAfter { + return 0, fmt.Errorf("write failed") + } + w.written += len(p) + return len(p), nil +} + +func TestFilterFilesByChannelPermissions_EdgeCases(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + t.Run("empty file list", func(t *testing.T) { + fileList := &model.FileInfoList{ + FileInfos: map[string]*model.FileInfo{}, + Order: []string{}, + } + allHaveMembership, appErr := th.App.FilterFilesByChannelPermissions(th.Context, fileList, th.BasicUser.Id) + require.Nil(t, appErr) + assert.True(t, allHaveMembership) + }) + + t.Run("nil file list", func(t *testing.T) { + allHaveMembership, appErr := th.App.FilterFilesByChannelPermissions(th.Context, nil, th.BasicUser.Id) + require.Nil(t, appErr) + assert.True(t, allHaveMembership) + }) + + t.Run("files from deleted channel", func(t *testing.T) { + // Create a channel + channel, appErr := th.App.CreateChannel(th.Context, &model.Channel{ + TeamId: th.BasicTeam.Id, + Type: model.ChannelTypeOpen, + Name: "test-channel", + DisplayName: "Test Channel", + }, false) + require.Nil(t, appErr) + + // Upload a file + data := []byte("test content") + info, appErr := th.App.UploadFile(th.Context, data, channel.Id, "test.txt") + require.Nil(t, appErr) + defer th.App.RemoveFile(info.Path) + defer th.App.Srv().Store().FileInfo().PermanentDelete(th.Context, info.Id) + + // Delete the channel + appErr = th.App.DeleteChannel(th.Context, channel, th.BasicUser.Id) + require.Nil(t, appErr) + + // Try to filter files + fileList := &model.FileInfoList{ + FileInfos: map[string]*model.FileInfo{ + info.Id: info, + }, + Order: []string{info.Id}, + } + allHaveMembership, appErr := th.App.FilterFilesByChannelPermissions(th.Context, fileList, th.BasicUser.Id) + require.Nil(t, appErr) + assert.False(t, allHaveMembership) + assert.Empty(t, fileList.FileInfos) + }) +} + +func TestFileModTime_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + t.Run("get mod time of non-existent file", func(t *testing.T) { + modTime, appErr := th.App.FileModTime("nonexistent/file.txt") + require.NotNil(t, appErr) + assert.True(t, modTime.IsZero()) + assert.Equal(t, "api.file.file_mod_time.app_error", appErr.Id) + assert.Equal(t, http.StatusInternalServerError, appErr.StatusCode) + }) +} + +func TestZipReader_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + t.Run("zip reader for non-existent file", func(t *testing.T) { + _, appErr := th.App.ZipReader("nonexistent/file.zip", true) + require.NotNil(t, appErr) + assert.Equal(t, "api.file.zip_file_reader.app_error", appErr.Id) + assert.Equal(t, http.StatusInternalServerError, appErr.StatusCode) + }) +} + +func TestExtractContentFromFileInfo_EdgeCases(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + t.Run("extract from image file", func(t *testing.T) { + // Create a simple PNG file info + info := &model.FileInfo{ + Id: model.NewId(), + Name: "test.png", + MimeType: "image/png", + Path: "test/path.png", + } + + // Should return nil for images + err := th.App.ExtractContentFromFileInfo(th.Context, info) + require.Nil(t, err) + }) + + t.Run("extract from file not in storage", func(t *testing.T) { + info := &model.FileInfo{ + Id: model.NewId(), + Name: "test.txt", + MimeType: "text/plain", + Path: "nonexistent/path.txt", + } + + err := th.App.ExtractContentFromFileInfo(th.Context, info) + require.NotNil(t, err) + assert.Contains(t, err.Error(), "failed to open file") + }) +} diff --git a/server/channels/app/notification_test_coverage.go b/server/channels/app/notification_test_coverage.go new file mode 100644 index 00000000000..7ec9fc3bf44 --- /dev/null +++ b/server/channels/app/notification_test_coverage.go @@ -0,0 +1,465 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost/server/public/model" +) + +func TestCanSendPushNotifications_ErrorPaths(t *testing.T) { + t.Run("disabled by config", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.EmailSettings.SendPushNotifications = false + }) + + result := th.App.canSendPushNotifications() + assert.False(t, result) + }) + + t.Run("MHPNS without license", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.Srv().SetLicense(nil) + + servers := []string{ + model.MHPNS, + model.MHPNSLegacyUS, + model.MHPNSLegacyDE, + model.MHPNSGlobal, + model.MHPNSUS, + model.MHPNSEU, + model.MHPNSAP, + } + + for _, server := range servers { + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.EmailSettings.SendPushNotifications = true + *cfg.EmailSettings.PushNotificationServer = server + }) + + result := th.App.canSendPushNotifications() + assert.False(t, result, "Should be false for server: %s", server) + } + }) + + t.Run("MHPNS with license but feature disabled", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + mhpnsFeature := false + license := &model.License{ + Features: &model.Features{ + MHPNS: &mhpnsFeature, + }, + } + th.App.Srv().SetLicense(license) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.EmailSettings.SendPushNotifications = true + *cfg.EmailSettings.PushNotificationServer = model.MHPNS + }) + + result := th.App.canSendPushNotifications() + assert.False(t, result) + }) +} + +func TestUserAllowsEmail_EdgeCases(t *testing.T) { + t.Run("system message with comments notify", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + user := &model.User{ + Id: model.NewId(), + NotifyProps: model.StringMap{ + model.EmailNotifyProp: model.UserNotifyNone, + model.CommentsNotifyProp: model.CommentsNotifyRoot, + model.PushStatusNotifyProp: model.StatusAway, + model.DesktopSoundNotifyProp: "true", + model.ChannelMentionsNotifyProp: "true", + }, + } + + systemPost := &model.Post{ + Type: model.PostTypeSystemGeneric, + Message: "system message", + } + + channelProps := model.StringMap{ + model.EmailNotifyProp: model.ChannelNotifyDefault, + } + + result := th.App.userAllowsEmail(th.Context, user, channelProps, systemPost) + assert.False(t, result, "System messages should not trigger email notifications") + }) + + t.Run("urgent post overrides notify props", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + user := &model.User{ + Id: model.NewId(), + NotifyProps: model.StringMap{ + model.EmailNotifyProp: model.UserNotifyNone, + }, + } + + urgentPost := &model.Post{ + Message: "urgent message", + Metadata: &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewPointer(model.PostPriorityUrgent), + }, + }, + } + + channelProps := model.StringMap{ + model.EmailNotifyProp: model.ChannelNotifyDefault, + } + + result := th.App.userAllowsEmail(th.Context, user, channelProps, urgentPost) + assert.True(t, result, "Urgent posts should override notification preferences") + }) + + t.Run("channel notify all overrides user setting", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + user := &model.User{ + Id: model.NewId(), + NotifyProps: model.StringMap{ + model.EmailNotifyProp: model.UserNotifyNone, + }, + } + + post := &model.Post{ + Message: "test message", + } + + channelProps := model.StringMap{ + model.EmailNotifyProp: model.ChannelNotifyAll, + } + + result := th.App.userAllowsEmail(th.Context, user, channelProps, post) + assert.True(t, result, "Channel notify all should override user none setting") + }) +} + +func TestSendNoUsersNotifiedByGroupInChannel(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + groupName := "testgroup" + group := &model.Group{ + Id: model.NewId(), + Name: &groupName, + DisplayName: "Test Group", + } + + post := &model.Post{ + Id: model.NewId(), + ChannelId: th.BasicChannel.Id, + Message: "@testgroup", + } + + // This should not panic and complete successfully + th.App.sendNoUsersNotifiedByGroupInChannel(th.Context, th.BasicUser, post, th.BasicChannel, group) +} + +func TestFilterUsersByVisible_ErrorPaths(t *testing.T) { + t.Run("nil viewer", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + otherUsers := []*model.User{th.BasicUser2} + + filtered, err := th.App.FilterUsersByVisible(th.Context, nil, otherUsers) + assert.NotNil(t, err) + assert.Nil(t, filtered) + }) + + t.Run("empty user list", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + filtered, err := th.App.FilterUsersByVisible(th.Context, th.BasicUser, []*model.User{}) + assert.Nil(t, err) + assert.Empty(t, filtered) + }) + + t.Run("filters deactivated users", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + deactivatedUser := th.CreateUser(t) + _, err := th.App.UpdateActive(th.Context, deactivatedUser, false) + require.Nil(t, err) + + otherUsers := []*model.User{th.BasicUser2, deactivatedUser} + + filtered, err := th.App.FilterUsersByVisible(th.Context, th.BasicUser, otherUsers) + assert.Nil(t, err) + assert.Len(t, filtered, 1) + assert.Equal(t, th.BasicUser2.Id, filtered[0].Id) + }) +} + +func TestGetGroupsAllowedForReferenceInChannel_ErrorCases(t *testing.T) { + t.Run("direct channel returns empty", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + dm, appErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, th.BasicUser2.Id) + require.Nil(t, appErr) + + groups, err := th.App.getGroupsAllowedForReferenceInChannel(dm, th.BasicTeam) + assert.NoError(t, err) + assert.Empty(t, groups) + }) + + t.Run("group message channel returns empty", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + gm := &model.Channel{ + Type: model.ChannelTypeGroup, + } + + groups, err := th.App.getGroupsAllowedForReferenceInChannel(gm, th.BasicTeam) + assert.NoError(t, err) + assert.Empty(t, groups) + }) +} + +func TestInsertGroupMentions_ErrorPaths(t *testing.T) { + t.Run("group with no members", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.Srv().SetLicense(getLicWithSkuShortName(model.LicenseShortSkuProfessional)) + + emptyGroupName := "emptygroup" + group := &model.Group{ + Id: model.NewId(), + Name: &emptyGroupName, + DisplayName: "Empty Group", + } + + mentions := &MentionResults{ + Mentions: make(map[string]MentionType), + } + profileMap := make(map[string]*model.User) + + anyMentioned, err := th.App.insertGroupMentions( + th.BasicUser.Id, + group, + th.BasicChannel, + profileMap, + mentions, + ) + + assert.NoError(t, err) + assert.False(t, anyMentioned) + assert.Empty(t, mentions.Mentions) + }) + + t.Run("group with inactive members only", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.Srv().SetLicense(getLicWithSkuShortName(model.LicenseShortSkuProfessional)) + + group := th.CreateGroup(t) + + inactiveUser := th.CreateUser(t) + _, err := th.App.UpdateActive(th.Context, inactiveUser, false) + require.Nil(t, err) + + _, err = th.App.UpsertGroupMember(group.Id, inactiveUser.Id) + require.Nil(t, err) + + mentions := &MentionResults{ + Mentions: make(map[string]MentionType), + } + profileMap := map[string]*model.User{ + inactiveUser.Id: inactiveUser, + } + + anyMentioned, err := th.App.insertGroupMentions( + th.BasicUser.Id, + group, + th.BasicChannel, + profileMap, + mentions, + ) + + assert.NoError(t, err) + assert.False(t, anyMentioned) + assert.Empty(t, mentions.Mentions) + }) +} + +func TestGetMentionKeywordsInChannel_EdgeCases(t *testing.T) { + t.Run("with channel mentions disabled", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + profiles := map[string]*model.User{ + th.BasicUser.Id: { + Id: th.BasicUser.Id, + Username: th.BasicUser.Username, + NotifyProps: model.StringMap{ + model.ChannelMentionsNotifyProp: "false", + }, + }, + } + + channelMemberProps := map[string]model.StringMap{ + th.BasicUser.Id: { + model.MarkUnreadNotifyProp: model.UserNotifyAll, + }, + } + + keywords := th.App.getMentionKeywordsInChannel( + profiles, + true, // allowChannelMentions + channelMemberProps, + nil, // groups + ) + + // Should not include @all, @here, @channel when disabled for user + _, hasAll := keywords["@all"] + _, hasHere := keywords["@here"] + _, hasChannel := keywords["@channel"] + assert.False(t, hasAll) + assert.False(t, hasHere) + assert.False(t, hasChannel) + }) + + t.Run("with custom mention keywords", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + profiles := map[string]*model.User{ + th.BasicUser.Id: { + Id: th.BasicUser.Id, + Username: th.BasicUser.Username, + NotifyProps: model.StringMap{ + model.MentionKeysNotifyProp: "custom1,custom2", + model.ChannelMentionsNotifyProp: "true", + }, + }, + } + + channelMemberProps := map[string]model.StringMap{ + th.BasicUser.Id: {}, + } + + keywords := th.App.getMentionKeywordsInChannel( + profiles, + true, + channelMemberProps, + nil, + ) + + _, hasCustom1 := keywords["custom1"] + _, hasCustom2 := keywords["custom2"] + _, hasUsername := keywords["@"+th.BasicUser.Username] + assert.True(t, hasCustom1) + assert.True(t, hasCustom2) + assert.True(t, hasUsername) + }) +} + +func TestRemoveNotifications_ErrorPaths(t *testing.T) { + t.Run("remove from archived channel", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + post := &model.Post{ + Id: model.NewId(), + ChannelId: th.BasicChannel.Id, + Message: "test", + } + + archivedChannel := &model.Channel{ + Id: th.BasicChannel.Id, + DeleteAt: model.GetMillis(), + } + + err := th.App.RemoveNotifications(th.Context, post, archivedChannel) + assert.NoError(t, err) // Should succeed without doing anything + }) +} + +func TestCountNotificationReason_EdgeCases(t *testing.T) { + t.Run("with metrics disabled", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.MetricsSettings.Enable = false + }) + + // Should not panic when metrics are disabled + th.App.CountNotificationReason( + model.NotificationStatusError, + model.NotificationTypePush, + model.NotificationReasonFetchError, + model.NotificationNoPlatform, + ) + }) + + t.Run("with all notification types and reasons", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.MetricsSettings.Enable = true + }) + + statuses := []model.NotificationStatus{ + model.NotificationStatusSuccess, + model.NotificationStatusNotSent, + model.NotificationStatusError, + } + + types := []model.NotificationType{ + model.NotificationTypePush, + model.NotificationTypeEmail, + model.NotificationTypeWebsocket, + } + + reasons := []model.NotificationReason{ + model.NotificationReasonFetchError, + model.NotificationReasonMissingProfile, + model.NotificationReasonChannelMuted, + } + + // Only use the one platform constant that exists + platforms := []string{ + model.NotificationNoPlatform, + } + + // Test all combinations - should not panic + for _, status := range statuses { + for _, notifType := range types { + for _, reason := range reasons { + for _, platform := range platforms { + th.App.CountNotificationReason(status, notifType, reason, platform) + } + } + } + } + }) +} diff --git a/server/channels/app/oauth_test_coverage.go b/server/channels/app/oauth_test_coverage.go new file mode 100644 index 00000000000..21cf2775880 --- /dev/null +++ b/server/channels/app/oauth_test_coverage.go @@ -0,0 +1,467 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost/server/public/model" +) + +func TestCreateOAuthApp_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + t.Run("OAuth disabled", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = false }) + + app := &model.OAuthApp{ + Name: "test", + CreatorId: th.BasicUser.Id, + CallbackUrls: []string{"https://example.com/callback"}, + } + + _, err := th.App.CreateOAuthApp(app) + assert.NotNil(t, err) + assert.Equal(t, "api.oauth.register_oauth_app.turn_off.app_error", err.Id) + }) + + t.Run("Duplicate app name for same creator", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = true }) + + app := &model.OAuthApp{ + Name: "duplicate-app", + CreatorId: th.BasicUser.Id, + CallbackUrls: []string{"https://example.com/callback"}, + } + + // Create first app + createdApp, err := th.App.CreateOAuthApp(app) + require.Nil(t, err) + require.NotNil(t, createdApp) + + // Try to create duplicate + app2 := &model.OAuthApp{ + Name: "duplicate-app", + CreatorId: th.BasicUser.Id, + CallbackUrls: []string{"https://different.com/callback"}, + } + + _, err = th.App.CreateOAuthApp(app2) + assert.NotNil(t, err) + assert.Equal(t, "app.oauth.save_app.existing.app_error", err.Id) + }) +} + +func TestUpdateOAuthApp_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + t.Run("OAuth disabled", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = false }) + + oldApp := &model.OAuthApp{Id: model.NewId()} + updatedApp := &model.OAuthApp{Name: "updated"} + + _, err := th.App.UpdateOAuthApp(oldApp, updatedApp) + assert.NotNil(t, err) + assert.Equal(t, "api.oauth.allow_oauth.turn_off.app_error", err.Id) + }) + + t.Run("Update non-existent app", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = true }) + + oldApp := &model.OAuthApp{ + Id: model.NewId(), + CreatorId: th.BasicUser.Id, + } + updatedApp := &model.OAuthApp{ + Name: "updated", + CallbackUrls: []string{"https://example.com"}, + } + + _, err := th.App.UpdateOAuthApp(oldApp, updatedApp) + assert.NotNil(t, err) + }) +} + +func TestDeleteOAuthApp_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + t.Run("OAuth disabled", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = false }) + + err := th.App.DeleteOAuthApp(th.Context, model.NewId()) + assert.NotNil(t, err) + assert.Equal(t, "api.oauth.allow_oauth.turn_off.app_error", err.Id) + }) + + t.Run("Delete non-existent app", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = true }) + + err := th.App.DeleteOAuthApp(th.Context, model.NewId()) + // Store returns nil for non-existent deletes, so this should succeed + assert.Nil(t, err) + }) +} + +func TestGetOAuthApp_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + t.Run("OAuth disabled", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = false }) + + _, err := th.App.GetOAuthApp(model.NewId()) + assert.NotNil(t, err) + assert.Equal(t, "api.oauth.allow_oauth.turn_off.app_error", err.Id) + }) + + t.Run("Non-existent app", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = true }) + + _, err := th.App.GetOAuthApp(model.NewId()) + assert.NotNil(t, err) + assert.Equal(t, "app.oauth.get_app.find.app_error", err.Id) + assert.Equal(t, http.StatusNotFound, err.StatusCode) + }) +} + +func TestGetOAuthApps_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + t.Run("OAuth disabled", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = false }) + + _, err := th.App.GetOAuthApps(0, 10) + assert.NotNil(t, err) + assert.Equal(t, "api.oauth.allow_oauth.turn_off.app_error", err.Id) + }) +} + +func TestAllowOAuthAppAccessToUser_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + t.Run("OAuth disabled", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = false }) + + authRequest := &model.AuthorizeRequest{ + ClientId: model.NewId(), + RedirectURI: "https://example.com/callback", + } + + _, err := th.App.AllowOAuthAppAccessToUser(th.Context, th.BasicUser.Id, authRequest) + assert.NotNil(t, err) + assert.Equal(t, "api.oauth.allow_oauth.turn_off.app_error", err.Id) + }) + + t.Run("Non-existent app", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = true }) + + authRequest := &model.AuthorizeRequest{ + ClientId: model.NewId(), + RedirectURI: "https://example.com/callback", + ResponseType: model.AuthCodeResponseType, + State: "test-state", + } + + _, err := th.App.AllowOAuthAppAccessToUser(th.Context, th.BasicUser.Id, authRequest) + assert.NotNil(t, err) + assert.Equal(t, "app.oauth.get_app.find.app_error", err.Id) + }) + + t.Run("Invalid redirect URI", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = true }) + + // Create OAuth app + app := &model.OAuthApp{ + Name: "test-app", + CreatorId: th.BasicUser.Id, + CallbackUrls: []string{"https://example.com/callback"}, + } + createdApp, err := th.App.CreateOAuthApp(app) + require.Nil(t, err) + + authRequest := &model.AuthorizeRequest{ + ClientId: createdApp.Id, + RedirectURI: "https://evil.com/callback", // Different from registered + ResponseType: model.AuthCodeResponseType, + State: "test-state", + } + + _, appErr := th.App.AllowOAuthAppAccessToUser(th.Context, th.BasicUser.Id, authRequest) + assert.NotNil(t, appErr) + assert.Equal(t, "api.oauth.allow_oauth.redirect_callback.app_error", appErr.Id) + }) + + t.Run("Public client without PKCE", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = true }) + + // Create public OAuth app (no client secret) + app := &model.OAuthApp{ + Name: "public-app", + CreatorId: th.BasicUser.Id, + CallbackUrls: []string{"https://example.com/callback"}, + } + createdApp, _ := th.App.CreateOAuthAppInternal(app, false) // Don't generate secret + require.Empty(t, createdApp.ClientSecret) + + authRequest := &model.AuthorizeRequest{ + ClientId: createdApp.Id, + RedirectURI: "https://example.com/callback", + ResponseType: model.AuthCodeResponseType, + State: "test-state", + CodeChallenge: "", // Missing PKCE challenge + } + + _, err := th.App.AllowOAuthAppAccessToUser(th.Context, th.BasicUser.Id, authRequest) + assert.NotNil(t, err) + assert.Equal(t, "api.oauth.allow_oauth.pkce_required_public.app_error", err.Id) + }) + + t.Run("Unsupported response type", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = true }) + + app := &model.OAuthApp{ + Name: "test-app", + CreatorId: th.BasicUser.Id, + CallbackUrls: []string{"https://example.com/callback"}, + } + createdApp, err := th.App.CreateOAuthApp(app) + require.Nil(t, err) + + authRequest := &model.AuthorizeRequest{ + ClientId: createdApp.Id, + RedirectURI: "https://example.com/callback", + ResponseType: "unsupported_type", + State: "test-state", + } + + redirectURI, appErr := th.App.AllowOAuthAppAccessToUser(th.Context, th.BasicUser.Id, authRequest) + assert.Nil(t, appErr) + assert.Contains(t, redirectURI, "error=unsupported_response_type") + assert.Contains(t, redirectURI, "state=test-state") + }) +} + +func TestDeauthorizeOAuthAppForUser_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + t.Run("OAuth disabled", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = false }) + + err := th.App.DeauthorizeOAuthAppForUser(th.Context, th.BasicUser.Id, model.NewId()) + assert.NotNil(t, err) + assert.Equal(t, "api.oauth.allow_oauth.turn_off.app_error", err.Id) + }) + + t.Run("Non-existent authorization", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = true }) + + // This should succeed even if preference doesn't exist + err := th.App.DeauthorizeOAuthAppForUser(th.Context, th.BasicUser.Id, model.NewId()) + assert.Nil(t, err) + }) +} + +func TestGetOAuthAccessTokenForCodeFlow_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + t.Run("OAuth disabled", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = false }) + + _, err := th.App.GetOAuthAccessTokenForCodeFlow(th.Context, "clientId", model.AccessTokenGrantType, "https://example.com", "code", "secret", "", "", "") + assert.NotNil(t, err) + assert.Equal(t, "api.oauth.get_access_token.disabled.app_error", err.Id) + }) + + t.Run("Invalid grant type", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = true }) + + app := &model.OAuthApp{ + Name: "test-app", + CreatorId: th.BasicUser.Id, + CallbackUrls: []string{"https://example.com/callback"}, + } + createdApp, err := th.App.CreateOAuthApp(app) + require.Nil(t, err) + + _, appErr := th.App.GetOAuthAccessTokenForCodeFlow(th.Context, createdApp.Id, "invalid_grant", "https://example.com", "code", createdApp.ClientSecret, "", "", "") + assert.NotNil(t, appErr) + assert.Equal(t, "api.oauth.get_access_token.bad_grant.app_error", appErr.Id) + }) + + t.Run("Non-existent client", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = true }) + + _, err := th.App.GetOAuthAccessTokenForCodeFlow(th.Context, model.NewId(), model.AccessTokenGrantType, "https://example.com", "code", "secret", "", "", "") + assert.NotNil(t, err) + assert.Equal(t, "api.oauth.get_access_token.credentials.app_error", err.Id) + }) + + t.Run("Invalid auth code", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = true }) + + app := &model.OAuthApp{ + Name: "test-app", + CreatorId: th.BasicUser.Id, + CallbackUrls: []string{"https://example.com/callback"}, + } + createdApp, err := th.App.CreateOAuthApp(app) + require.Nil(t, err) + + _, appErr := th.App.GetOAuthAccessTokenForCodeFlow(th.Context, createdApp.Id, model.AccessTokenGrantType, "https://example.com/callback", "invalid_code", createdApp.ClientSecret, "", "", "") + assert.NotNil(t, appErr) + assert.Equal(t, "api.oauth.get_access_token.expired_code.app_error", appErr.Id) + }) + + t.Run("Wrong client secret", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = true }) + + app := &model.OAuthApp{ + Name: "test-app", + CreatorId: th.BasicUser.Id, + CallbackUrls: []string{"https://example.com/callback"}, + } + createdApp, err := th.App.CreateOAuthApp(app) + require.Nil(t, err) + + // Create auth code + authData := &model.AuthData{ + UserId: th.BasicUser.Id, + ClientId: createdApp.Id, + CreateAt: model.GetMillis(), + RedirectUri: "https://example.com/callback", + State: "test", + Scope: model.DefaultScope, + Code: model.NewId() + model.NewId(), + } + _, nErr := th.App.Srv().Store().OAuth().SaveAuthData(authData) + require.NoError(t, nErr) + + _, appErr := th.App.GetOAuthAccessTokenForCodeFlow(th.Context, createdApp.Id, model.AccessTokenGrantType, authData.RedirectUri, authData.Code, "wrong_secret", "", "", "") + assert.NotNil(t, appErr) + assert.Equal(t, "api.oauth.get_access_token.credentials.app_error", appErr.Id) + }) +} + +func TestRegenerateOAuthAppSecret_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + t.Run("OAuth disabled", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = false }) + + app := &model.OAuthApp{Id: model.NewId()} + _, err := th.App.RegenerateOAuthAppSecret(app) + assert.NotNil(t, err) + assert.Equal(t, "api.oauth.allow_oauth.turn_off.app_error", err.Id) + }) +} + +func TestRevokeAccessToken_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + t.Run("Invalid token", func(t *testing.T) { + err := th.App.RevokeAccessToken(th.Context, "invalid_token") + // Returns nil even if token not found + assert.Nil(t, err) + }) +} + +func TestGetOAuthCodeRedirect_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + t.Run("Invalid redirect URI", func(t *testing.T) { + authRequest := &model.AuthorizeRequest{ + ClientId: model.NewId(), + RedirectURI: "://invalid-uri", // Invalid URI format + State: "test-state", + } + + redirectURI, err := th.App.GetOAuthCodeRedirect(th.BasicUser.Id, authRequest) + assert.Nil(t, err) + assert.Contains(t, redirectURI, "error=redirect_uri_parse_error") + assert.Contains(t, redirectURI, "state=test-state") + }) + + t.Run("Store save failure", func(t *testing.T) { + // This test would require mocking the store to simulate a failure + // Since we're using real stores, we can't easily simulate this error path + // The code handles it properly by returning an error query parameter + }) +} + +func TestGetAuthorizedAppsForUser_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + t.Run("Invalid user", func(t *testing.T) { + apps, err := th.App.GetAuthorizedAppsForUser(model.NewId(), 0, 10) + // Should return empty list, not error + assert.Nil(t, err) + assert.Empty(t, apps) + }) + + t.Run("Store error handling", func(t *testing.T) { + // Create an OAuth app and authorize it + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = true }) + + app := &model.OAuthApp{ + Name: "test-app", + CreatorId: th.BasicUser.Id, + CallbackUrls: []string{"https://example.com/callback"}, + } + createdApp, err := th.App.CreateOAuthApp(app) + require.Nil(t, err) + + // Authorize the app + pref := model.Preference{ + UserId: th.BasicUser.Id, + Category: model.PreferenceCategoryAuthorizedOAuthApp, + Name: createdApp.Id, + Value: model.DefaultScope, + } + nErr := th.App.Srv().Store().Preference().Save(model.Preferences{pref}) + require.NoError(t, nErr) + + // Get authorized apps + apps, appErr := th.App.GetAuthorizedAppsForUser(th.BasicUser.Id, 0, 10) + assert.Nil(t, appErr) + assert.Len(t, apps, 1) + + // Delete the app to simulate orphaned preference + appErr = th.App.DeleteOAuthApp(th.Context, createdApp.Id) + require.Nil(t, appErr) + + // Should handle missing app gracefully + apps, appErr = th.App.GetAuthorizedAppsForUser(th.BasicUser.Id, 0, 10) + assert.Nil(t, appErr) + assert.Empty(t, apps) + }) +} + +func TestGetOAuthAppsByCreator_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + t.Run("OAuth disabled", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = false }) + + _, err := th.App.GetOAuthAppsByCreator(th.BasicUser.Id, 0, 10) + assert.NotNil(t, err) + assert.Equal(t, "api.oauth.allow_oauth.turn_off.app_error", err.Id) + }) +} diff --git a/server/channels/app/plugin_test_coverage.go b/server/channels/app/plugin_test_coverage.go new file mode 100644 index 00000000000..c1d8f384a08 --- /dev/null +++ b/server/channels/app/plugin_test_coverage.go @@ -0,0 +1,419 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "bytes" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/plugin" +) + +// TestEnablePlugin_ErrorPaths tests error handling for EnablePlugin +func TestEnablePlugin_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + mainHelper.Parallel(t) + t.Run("plugins disabled", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.Enable = false + }) + + err := th.App.EnablePlugin("test-plugin") + assert.NotNil(t, err) + assert.Equal(t, "app.plugin.disabled.app_error", err.Id) + assert.Equal(t, http.StatusNotImplemented, err.StatusCode) + }) + + t.Run("plugin not found", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.Enable = true + }) + + err := th.App.EnablePlugin("non-existent-plugin") + assert.NotNil(t, err) + assert.Equal(t, "app.plugin.not_installed.app_error", err.Id) + assert.Equal(t, http.StatusNotFound, err.StatusCode) + }) + + t.Run("already enabled plugin", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.Enable = true + cfg.PluginSettings.PluginStates["test-plugin"] = &model.PluginState{Enable: true} + }) + + // Try to enable an already enabled plugin - should not error but be idempotent + err := th.App.EnablePlugin("non-existent-plugin") + assert.NotNil(t, err) + assert.Equal(t, "app.plugin.not_installed.app_error", err.Id) + }) +} + +// TestDisablePlugin_ErrorPaths tests error handling for DisablePlugin +func TestDisablePlugin_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + mainHelper.Parallel(t) + t.Run("plugins disabled", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.Enable = false + }) + + err := th.App.DisablePlugin("test-plugin") + assert.NotNil(t, err) + assert.Equal(t, "app.plugin.disabled.app_error", err.Id) + assert.Equal(t, http.StatusNotImplemented, err.StatusCode) + }) + + t.Run("plugin not found", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.Enable = true + }) + + err := th.App.DisablePlugin("non-existent-plugin") + assert.NotNil(t, err) + assert.Equal(t, "app.plugin.not_installed.app_error", err.Id) + assert.Equal(t, http.StatusNotFound, err.StatusCode) + }) + + t.Run("already disabled plugin", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.Enable = true + cfg.PluginSettings.PluginStates["test-plugin"] = &model.PluginState{Enable: false} + }) + + // Try to disable an already disabled plugin - should not error but be idempotent + err := th.App.DisablePlugin("non-existent-plugin") + assert.NotNil(t, err) + assert.Equal(t, "app.plugin.not_installed.app_error", err.Id) + }) +} + +// TestInstallPlugin_ErrorPaths tests error handling for InstallPlugin +func TestInstallPlugin_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + t.Run("plugins disabled", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.Enable = false + }) + + manifest, err := th.App.InstallPlugin(bytes.NewReader([]byte("test")), false) + assert.Nil(t, manifest) + assert.NotNil(t, err) + assert.Equal(t, "app.plugin.disabled.app_error", err.Id) + assert.Equal(t, http.StatusNotImplemented, err.StatusCode) + }) + + t.Run("invalid plugin file", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.Enable = true + }) + + manifest, err := th.App.InstallPlugin(bytes.NewReader([]byte("not a valid tar.gz")), false) + assert.Nil(t, manifest) + assert.NotNil(t, err) + assert.Equal(t, "app.plugin.extract.app_error", err.Id) + }) + + t.Run("nil reader", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.Enable = true + }) + + manifest, err := th.App.InstallPlugin(&nilPluginReader{}, false) + assert.Nil(t, manifest) + assert.NotNil(t, err) + assert.Equal(t, "app.plugin.extract.app_error", err.Id) + }) +} + +// TestRemovePlugin_ErrorPaths tests error handling for RemovePlugin +func TestRemovePlugin_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + t.Run("plugins disabled", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.Enable = false + }) + + err := th.App.ch.RemovePlugin("test-plugin") + assert.NotNil(t, err) + assert.Equal(t, "app.plugin.disabled.app_error", err.Id) + assert.Equal(t, http.StatusNotImplemented, err.StatusCode) + }) + + t.Run("plugin not found", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.Enable = true + }) + + err := th.App.ch.RemovePlugin("non-existent-plugin") + assert.NotNil(t, err) + assert.Equal(t, "app.plugin.not_installed.app_error", err.Id) + assert.Equal(t, http.StatusNotFound, err.StatusCode) + }) + + t.Run("cannot remove prepackaged plugin", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.Enable = true + }) + + // Set up a mock prepackaged plugin in the environment + env := th.App.GetPluginsEnvironment() + if env != nil { + prepackagedPlugins := []*plugin.PrepackagedPlugin{ + { + Manifest: &model.Manifest{ + Id: "prepackaged-plugin", + Version: "1.0.0", + }, + }, + } + env.SetPrepackagedPlugins(prepackagedPlugins, nil) + } + + err := th.App.ch.RemovePlugin("prepackaged-plugin") + assert.NotNil(t, err) + assert.Equal(t, "app.plugin.prepackaged.app_error", err.Id) + assert.Equal(t, http.StatusBadRequest, err.StatusCode) + }) +} + +// TestGetPlugins_ErrorPaths tests error handling for GetPlugins +func TestGetPlugins_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + t.Run("plugins disabled", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.Enable = false + }) + + resp, err := th.App.GetPlugins() + assert.Nil(t, resp) + assert.NotNil(t, err) + assert.Equal(t, "app.plugin.disabled.app_error", err.Id) + assert.Equal(t, http.StatusNotImplemented, err.StatusCode) + }) + + t.Run("empty plugin list", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.Enable = true + }) + + resp, err := th.App.GetPlugins() + assert.Nil(t, err) + assert.NotNil(t, resp) + assert.Empty(t, resp.Active) + assert.Empty(t, resp.Inactive) + }) +} + +// TestGetPluginStatuses_ErrorPaths tests error handling for GetPluginStatuses +func TestGetPluginStatuses_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + t.Run("plugins disabled", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.Enable = false + }) + + statuses, err := th.App.GetPluginStatuses() + assert.Nil(t, statuses) + assert.NotNil(t, err) + assert.Equal(t, "app.plugin.disabled.app_error", err.Id) + assert.Equal(t, http.StatusNotImplemented, err.StatusCode) + }) + + t.Run("empty status list", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.Enable = true + }) + + statuses, err := th.App.GetPluginStatuses() + assert.Nil(t, err) + assert.NotNil(t, statuses) + assert.Empty(t, statuses) + }) +} + +// TestGetPluginStatus_SinglePlugin tests GetPluginStatus for edge cases +func TestGetPluginStatus_SinglePlugin(t *testing.T) { + mainHelper.Parallel(t) + t.Run("plugins disabled", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.Enable = false + }) + + status, err := th.App.GetPluginStatus("test-plugin") + assert.Nil(t, status) + assert.NotNil(t, err) + assert.Equal(t, "app.plugin.disabled.app_error", err.Id) + assert.Equal(t, http.StatusNotImplemented, err.StatusCode) + }) + + t.Run("plugin not found", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.Enable = true + }) + + status, err := th.App.GetPluginStatus("non-existent-plugin") + assert.Nil(t, status) + assert.NotNil(t, err) + assert.Equal(t, "app.plugin.not_installed.app_error", err.Id) + assert.Equal(t, http.StatusNotFound, err.StatusCode) + }) +} + +// TestPluginStateManagement_EdgeCases tests plugin state management edge cases +func TestPluginStateManagement_EdgeCases(t *testing.T) { + mainHelper.Parallel(t) + t.Run("enable then disable quickly", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.Enable = true + *cfg.PluginSettings.RequirePluginSignature = false + }) + + // First we need a plugin to work with + // This is a basic test to ensure state changes work properly + // In a real scenario, we'd have an actual plugin installed + + err := th.App.EnablePlugin("non-existent") + require.NotNil(t, err) + assert.Equal(t, "app.plugin.not_installed.app_error", err.Id) + + err = th.App.DisablePlugin("non-existent") + require.NotNil(t, err) + assert.Equal(t, "app.plugin.not_installed.app_error", err.Id) + }) + + t.Run("config save failure", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.Enable = true + }) + + // Test that even with a non-existent plugin, we handle the error correctly + err := th.App.EnablePlugin("test-plugin-not-found") + assert.NotNil(t, err) + assert.Equal(t, "app.plugin.not_installed.app_error", err.Id) + }) +} + +// TestSyncPlugins_ErrorPaths tests error cases in plugin synchronization +func TestSyncPlugins_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + t.Run("plugins disabled", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.Enable = false + }) + + err := th.App.SyncPlugins() + assert.NotNil(t, err) + assert.Equal(t, "app.plugin.disabled.app_error", err.Id) + assert.Equal(t, http.StatusNotImplemented, err.StatusCode) + }) + + t.Run("no plugins to sync", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.Enable = true + }) + + // With no plugins in the file store, sync should succeed but do nothing + err := th.App.SyncPlugins() + assert.Nil(t, err) + }) +} + +// TestPluginEnvironmentNil tests handling when plugin environment is nil +func TestPluginEnvironmentNil(t *testing.T) { + mainHelper.Parallel(t) + t.Run("get plugins environment when disabled", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.Enable = false + }) + + env := th.App.GetPluginsEnvironment() + assert.Nil(t, env) + }) +} + +// Helper type for simulating nil/empty plugin readers +type nilPluginReader struct{} + +func (r *nilPluginReader) Read(p []byte) (int, error) { + return 0, io.EOF +} + +func (r *nilPluginReader) Seek(offset int64, whence int) (int64, error) { + return 0, nil +} diff --git a/server/channels/app/user_test_coverage.go b/server/channels/app/user_test_coverage.go new file mode 100644 index 00000000000..5116ca8ddfb --- /dev/null +++ b/server/channels/app/user_test_coverage.go @@ -0,0 +1,440 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost/server/public/model" +) + +func TestCreateUser_DuplicateEmail(t *testing.T) { + mainHelper.Parallel(t) + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // Create a user with unique email + user1 := &model.User{ + Email: model.NewId() + "@example.com", + Username: model.NewId() + "_user1", + Password: "Password1", + } + createdUser1, err := th.App.CreateUser(th.Context, user1) + require.Nil(t, err) + require.NotNil(t, createdUser1) + + // Try to create another user with the same email + user2 := &model.User{ + Email: user1.Email, // Same email + Username: model.NewId() + "_user2", + Password: "Password1", + } + _, err = th.App.CreateUser(th.Context, user2) + require.NotNil(t, err) + assert.Equal(t, "app.user.save.email_exists.app_error", err.Id) + assert.Equal(t, 400, err.StatusCode) +} + +func TestCreateUser_DuplicateUsername(t *testing.T) { + mainHelper.Parallel(t) + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // Create a user with unique username + user1 := &model.User{ + Email: model.NewId() + "@example.com", + Username: "testuser_" + model.NewId(), + Password: "Password1", + } + createdUser1, err := th.App.CreateUser(th.Context, user1) + require.Nil(t, err) + require.NotNil(t, createdUser1) + + // Try to create another user with the same username + user2 := &model.User{ + Email: model.NewId() + "@example.com", + Username: user1.Username, // Same username + Password: "Password1", + } + _, err = th.App.CreateUser(th.Context, user2) + require.NotNil(t, err) + assert.Equal(t, "app.user.save.username_exists.app_error", err.Id) + assert.Equal(t, 400, err.StatusCode) +} + +func TestCreateUser_RestrictedDomain(t *testing.T) { + mainHelper.Parallel(t) + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // Enable email domain restrictions + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.TeamSettings.RestrictCreationToDomains = "allowed.com" + }) + + // Try to create user with disallowed domain + user := &model.User{ + Email: "user@notallowed.com", + Username: "testuser_" + model.NewId(), + Password: "Password1", + } + _, err := th.App.CreateUser(th.Context, user) + require.NotNil(t, err) + assert.Equal(t, "api.user.create_user.accepted_domain.app_error", err.Id) + assert.Equal(t, 400, err.StatusCode) + + // Create user with allowed domain should work + allowedUser := &model.User{ + Email: "user@allowed.com", + Username: "testuser_" + model.NewId(), + Password: "Password1", + } + createdUser, err := th.App.CreateUser(th.Context, allowedUser) + require.Nil(t, err) + require.NotNil(t, createdUser) +} + +func TestCreateUser_AtUserLimit(t *testing.T) { + mainHelper.Parallel(t) + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // Set a very low user limit + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.TeamSettings.MaxUsersPerTeam = 1 + }) + + // Try to create a user when at limit + user := &model.User{ + Email: model.NewId() + "@example.com", + Username: "testuser_" + model.NewId(), + Password: "Password1", + } + _, err := th.App.CreateUser(th.Context, user) + require.NotNil(t, err) + assert.Equal(t, "api.user.create_user.user_limits.exceeded", err.Id) + assert.Equal(t, 400, err.StatusCode) +} + +func TestGetUser_NotFound(t *testing.T) { + mainHelper.Parallel(t) + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // Try to get non-existent user + _, err := th.App.GetUser(model.NewId()) + require.NotNil(t, err) + assert.Equal(t, MissingAccountError, err.Id) + assert.Equal(t, 404, err.StatusCode) +} + +func TestGetUserByUsername_NotFound(t *testing.T) { + mainHelper.Parallel(t) + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // Try to get user by non-existent username + _, err := th.App.GetUserByUsername("nonexistentusername") + require.NotNil(t, err) + assert.Equal(t, "app.user.get_by_username.app_error", err.Id) + assert.Equal(t, 404, err.StatusCode) +} + +func TestGetUserByEmail_NotFound(t *testing.T) { + mainHelper.Parallel(t) + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // Try to get user by non-existent email + _, err := th.App.GetUserByEmail("nonexistent@example.com") + require.NotNil(t, err) + assert.Equal(t, MissingAccountError, err.Id) + assert.Equal(t, 404, err.StatusCode) +} + +func TestUpdatePassword_InvalidUser(t *testing.T) { + mainHelper.Parallel(t) + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // Try to update password for non-existent user + err := th.App.UpdatePasswordAsUser(th.Context, model.NewId(), "currentPassword", "newPassword123") + require.NotNil(t, err) + assert.Equal(t, MissingAccountError, err.Id) + assert.Equal(t, 404, err.StatusCode) +} + +func TestUpdatePassword_OAuthUser(t *testing.T) { + mainHelper.Parallel(t) + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // Create OAuth user + user := th.CreateUser(t) + authData := model.NewId() + th.App.Srv().Store().User().UpdateAuthData(user.Id, model.ServiceGitlab, &authData, "", false) + + // Get updated user + oauthUser, _ := th.App.GetUser(user.Id) + + // Try to update password for OAuth user + err := th.App.UpdatePasswordAsUser(th.Context, oauthUser.Id, "currentPassword", "newPassword123") + require.NotNil(t, err) + assert.Equal(t, "api.user.update_password.oauth.app_error", err.Id) + assert.Equal(t, 400, err.StatusCode) +} + +func TestUpdatePassword_IncorrectCurrentPassword(t *testing.T) { + mainHelper.Parallel(t) + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // Try to update with wrong current password + err := th.App.UpdatePasswordAsUser(th.Context, th.BasicUser.Id, "wrongpassword", "newPassword123") + require.NotNil(t, err) + assert.Equal(t, "api.user.update_password.incorrect.app_error", err.Id) + assert.Equal(t, 400, err.StatusCode) +} + +func TestUpdateUser_EmailChangeRestriction(t *testing.T) { + mainHelper.Parallel(t) + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // Get the basic user + user, _ := th.App.GetUser(th.BasicUser.Id) + originalEmail := user.Email + + // Try to change email when it's restricted + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.EmailSettings.RequireEmailVerification = true + }) + + user.Email = "newemail@example.com" + _, err := th.App.UpdateUserAsUser(th.Context, user, false) + require.NotNil(t, err) + assert.Equal(t, "api.user.update_user.email_change.app_error", err.Id) + assert.Equal(t, 400, err.StatusCode) + + // Verify email wasn't changed + updatedUser, _ := th.App.GetUser(user.Id) + assert.Equal(t, originalEmail, updatedUser.Email) +} + +func TestPatchUser_InvalidPatch(t *testing.T) { + mainHelper.Parallel(t) + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // Try to patch non-existent user + patch := &model.UserPatch{ + Username: model.NewPointer("newusername"), + } + _, err := th.App.PatchUser(th.Context, model.NewId(), patch, false) + require.NotNil(t, err) + assert.Equal(t, MissingAccountError, err.Id) + assert.Equal(t, 404, err.StatusCode) + + // Try to patch with invalid username + invalidPatch := &model.UserPatch{ + Username: model.NewPointer("a"), // Too short + } + _, err = th.App.PatchUser(th.Context, th.BasicUser.Id, invalidPatch, false) + require.NotNil(t, err) + assert.Equal(t, "app.user.update.find.app_error", err.Id) + assert.Equal(t, 400, err.StatusCode) +} + +func TestDeactivateUser_NotFound(t *testing.T) { + mainHelper.Parallel(t) + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // Try to deactivate non-existent user + err := th.App.UpdateUserActive(th.Context, model.NewId(), false) + require.NotNil(t, err) + assert.Equal(t, MissingAccountError, err.Id) + assert.Equal(t, 404, err.StatusCode) +} + +func TestUpdateActive_AtUserLimitReactivation(t *testing.T) { + mainHelper.Parallel(t) + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // First deactivate a user + user := th.CreateUser(t) + err := th.App.UpdateUserActive(th.Context, user.Id, false) + require.Nil(t, err) + + // Set user limit that we're at + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.TeamSettings.MaxUsersPerTeam = 3 // We have BasicUser, BasicUser2, and SystemAdminUser + }) + + // Try to reactivate when at limit + err = th.App.UpdateUserActive(th.Context, user.Id, true) + require.NotNil(t, err) + assert.Equal(t, "app.user.update_active.user_limit.exceeded", err.Id) + assert.Equal(t, 400, err.StatusCode) +} + +func TestResetPasswordFromCode_InvalidCode(t *testing.T) { + mainHelper.Parallel(t) + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // Try to reset password with invalid token + err := th.App.ResetPasswordFromToken(th.Context, "invalidtoken", "newPassword123") + require.NotNil(t, err) + assert.Equal(t, "api.user.reset_password.invalid_link.app_error", err.Id) + assert.Equal(t, 400, err.StatusCode) +} + +func TestCreateUserWithInviteId_RestrictedDomain(t *testing.T) { + mainHelper.Parallel(t) + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // Set allowed domains on team + restoreTeam := saveTeamState(th) + defer restoreTeam() + + th.BasicTeam.AllowedDomains = "allowed.com" + _, err := th.App.UpdateTeam(th.BasicTeam) + require.Nil(t, err) + + // Try to create user with disallowed domain + user := &model.User{ + Email: "user@notallowed.com", + Username: "testuser_" + model.NewId(), + Password: "Password1", + } + _, err = th.App.CreateUserWithInviteId(th.Context, user, th.BasicTeam.InviteId, "") + require.NotNil(t, err) + assert.Equal(t, "api.team.invite_members.invalid_email.app_error", err.Id) + assert.Equal(t, 403, err.StatusCode) +} + +func TestUpdateUser_UsernameConflictWithGroup(t *testing.T) { + mainHelper.Parallel(t) + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // Create a group + groupName := strings.ToLower(model.NewId()) + group := &model.Group{ + Name: &groupName, + DisplayName: "Test Group", + RemoteId: nil, + Source: model.GroupSourceCustom, + } + createdGroup, err := th.App.CreateGroup(group) + require.Nil(t, err) + + // Try to update user with username matching group name + user, _ := th.App.GetUser(th.BasicUser.Id) + user.Username = *createdGroup.Name + _, appErr := th.App.UpdateUser(th.Context, user, false) + require.NotNil(t, appErr) + assert.Equal(t, "app.user.username_taken_by_group.app_error", appErr.Id) + assert.Equal(t, 400, appErr.StatusCode) +} + +func TestPermanentDeleteUser_FailStoreDelete(t *testing.T) { + mainHelper.Parallel(t) + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // Create a user to delete + user := th.CreateUser(t) + + // Add the user to channels and create some posts to make deletion more complex + th.LinkUserToTeam(t, user, th.BasicTeam) + th.AddUserToChannel(t, user, th.BasicChannel) + post := &model.Post{ + UserId: user.Id, + ChannelId: th.BasicChannel.Id, + Message: "Test message", + } + _, _, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{}) + require.Nil(t, err) + + // Deactivate first (required before permanent delete) + err = th.App.UpdateUserActive(th.Context, user.Id, false) + require.Nil(t, err) + + // Try permanent delete - it should succeed but let's verify it handles errors gracefully + err = th.App.PermanentDeleteUser(th.Context, user) + require.Nil(t, err) + + // Verify user is gone + _, err = th.App.GetUser(user.Id) + require.NotNil(t, err) + assert.Equal(t, MissingAccountError, err.Id) +} + +func TestCreateUserFromSignup_NotOpenServer(t *testing.T) { + mainHelper.Parallel(t) + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // Disable open server + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.TeamSettings.EnableOpenServer = false + }) + + // Make sure it's not first account + users, _ := th.App.Srv().Store().User().GetAll() + require.Greater(t, len(users), 0) + + // Try to create user via signup + user := &model.User{ + Email: model.NewId() + "@example.com", + Username: "testuser_" + model.NewId(), + Password: "Password1", + } + _, err := th.App.CreateUserFromSignup(th.Context, user, "") + require.NotNil(t, err) + assert.Equal(t, "api.user.create_user.no_open_server", err.Id) + assert.Equal(t, 403, err.StatusCode) +} + +func TestGetUserByAuth_MissingAuthData(t *testing.T) { + mainHelper.Parallel(t) + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // Try to get user by auth data that doesn't exist + _, err := th.App.GetUserByAuth(model.NewPointer("nonexistentauth"), model.ServiceGitlab) + require.NotNil(t, err) + assert.Equal(t, MissingAuthAccountError, err.Id) + assert.Equal(t, 400, err.StatusCode) +} + +func TestUpdatePasswordSendEmail_WeakPassword(t *testing.T) { + mainHelper.Parallel(t) + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // Enable strict password requirements + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PasswordSettings.MinimumLength = 12 + *cfg.PasswordSettings.Lowercase = true + *cfg.PasswordSettings.Uppercase = true + *cfg.PasswordSettings.Symbol = true + *cfg.PasswordSettings.Number = true + }) + + // Try to set weak password + user, _ := th.App.GetUser(th.BasicUser.Id) + err := th.App.UpdatePassword(th.Context, user, "weak") + require.NotNil(t, err) + assert.Equal(t, "model.user.is_valid.pwd", err.Id) + assert.Equal(t, 400, err.StatusCode) +} diff --git a/server/channels/app/webhook_test_coverage.go b/server/channels/app/webhook_test_coverage.go new file mode 100644 index 00000000000..b2c1dccc2d5 --- /dev/null +++ b/server/channels/app/webhook_test_coverage.go @@ -0,0 +1,460 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost/server/public/model" +) + +func TestDeleteIncomingWebhook_Error(t *testing.T) { + mainHelper.Parallel(t) + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // Test when webhooks are disabled + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableIncomingWebhooks = false }) + + err := th.App.DeleteIncomingWebhook("nonexistent") + require.NotNil(t, err) + assert.Equal(t, "api.incoming_webhook.disabled.app_error", err.Id) + + // Test deletion of non-existent webhook + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableIncomingWebhooks = true }) + + err = th.App.DeleteIncomingWebhook(model.NewId()) + require.Nil(t, err) // Delete is idempotent, no error for non-existent +} + +func TestGetIncomingWebhook_Errors(t *testing.T) { + mainHelper.Parallel(t) + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // Test when webhooks are disabled + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableIncomingWebhooks = false }) + + _, err := th.App.GetIncomingWebhook("test") + require.NotNil(t, err) + assert.Equal(t, "api.incoming_webhook.disabled.app_error", err.Id) + + // Test getting non-existent webhook + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableIncomingWebhooks = true }) + + _, err = th.App.GetIncomingWebhook(model.NewId()) + require.NotNil(t, err) + assert.Equal(t, http.StatusNotFound, err.StatusCode) +} + +func TestGetIncomingWebhooksPageByUser_DisabledError(t *testing.T) { + mainHelper.Parallel(t) + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // Test when webhooks are disabled + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableIncomingWebhooks = false }) + + _, err := th.App.GetIncomingWebhooksPageByUser(th.BasicUser.Id, 0, 10) + require.NotNil(t, err) + assert.Equal(t, "api.incoming_webhook.disabled.app_error", err.Id) +} + +func TestGetIncomingWebhooksCount_DisabledError(t *testing.T) { + mainHelper.Parallel(t) + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // Test when webhooks are disabled + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableIncomingWebhooks = false }) + + count, err := th.App.GetIncomingWebhooksCount(th.BasicTeam.Id, th.BasicUser.Id) + require.NotNil(t, err) + assert.Equal(t, "api.incoming_webhook.disabled.app_error", err.Id) + assert.Equal(t, int64(0), count) +} + +func TestCreateOutgoingWebhook_Errors(t *testing.T) { + mainHelper.Parallel(t) + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // Test when webhooks are disabled + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOutgoingWebhooks = false }) + + hook := &model.OutgoingWebhook{ + ChannelId: th.BasicChannel.Id, + TeamId: th.BasicTeam.Id, + CallbackURLs: []string{"http://example.com"}, + CreatorId: th.BasicUser.Id, + } + + _, err := th.App.CreateOutgoingWebhook(hook) + require.NotNil(t, err) + assert.Equal(t, "api.outgoing_webhook.disabled.app_error", err.Id) + + // Test with non-existent channel + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOutgoingWebhooks = true }) + + hook.ChannelId = model.NewId() + _, err = th.App.CreateOutgoingWebhook(hook) + require.NotNil(t, err) + assert.Equal(t, http.StatusNotFound, err.StatusCode) + + // Test with private channel + privateChannel := th.CreatePrivateChannel(t, th.BasicTeam) + hook.ChannelId = privateChannel.Id + _, err = th.App.CreateOutgoingWebhook(hook) + require.NotNil(t, err) + assert.Equal(t, http.StatusForbidden, err.StatusCode) + + // Test with no channel and no trigger words + hook.ChannelId = "" + hook.TriggerWords = []string{} + _, err = th.App.CreateOutgoingWebhook(hook) + require.NotNil(t, err) + assert.Equal(t, "api.webhook.create_outgoing.triggers.app_error", err.Id) +} + +func TestUpdateOutgoingWebhook_Errors(t *testing.T) { + mainHelper.Parallel(t) + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // Create a webhook to update + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOutgoingWebhooks = true }) + + oldHook := &model.OutgoingWebhook{ + ChannelId: th.BasicChannel.Id, + TeamId: th.BasicTeam.Id, + CallbackURLs: []string{"http://example.com"}, + CreatorId: th.BasicUser.Id, + TriggerWords: []string{"trigger"}, + } + createdHook, err := th.App.CreateOutgoingWebhook(oldHook) + require.Nil(t, err) + + // Test when webhooks are disabled + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOutgoingWebhooks = false }) + + updatedHook := *createdHook + updatedHook.DisplayName = "Updated" + _, err = th.App.UpdateOutgoingWebhook(th.Context, createdHook, &updatedHook) + require.NotNil(t, err) + assert.Equal(t, "api.outgoing_webhook.disabled.app_error", err.Id) + + // Test updating to private channel + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOutgoingWebhooks = true }) + + privateChannel := th.CreatePrivateChannel(t, th.BasicTeam) + updatedHook.ChannelId = privateChannel.Id + _, err = th.App.UpdateOutgoingWebhook(th.Context, createdHook, &updatedHook) + require.NotNil(t, err) + assert.Equal(t, "api.webhook.create_outgoing.not_open.app_error", err.Id) + + // Test updating to different team's channel + otherTeam := th.CreateTeam(t) + otherChannel := th.CreateChannel(t, otherTeam) + updatedHook.ChannelId = otherChannel.Id + _, err = th.App.UpdateOutgoingWebhook(th.Context, createdHook, &updatedHook) + require.NotNil(t, err) + assert.Equal(t, "api.webhook.create_outgoing.permissions.app_error", err.Id) + + // Test removing channel ID without trigger words + updatedHook.ChannelId = "" + updatedHook.TriggerWords = []string{} + _, err = th.App.UpdateOutgoingWebhook(th.Context, createdHook, &updatedHook) + require.NotNil(t, err) + assert.Equal(t, "api.webhook.create_outgoing.triggers.app_error", err.Id) +} + +func TestDeleteOutgoingWebhook_Error(t *testing.T) { + mainHelper.Parallel(t) + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // Test when webhooks are disabled + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOutgoingWebhooks = false }) + + err := th.App.DeleteOutgoingWebhook("test") + require.NotNil(t, err) + assert.Equal(t, "api.outgoing_webhook.disabled.app_error", err.Id) + + // Test deletion of non-existent webhook + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOutgoingWebhooks = true }) + + err = th.App.DeleteOutgoingWebhook(model.NewId()) + require.Nil(t, err) // Delete is idempotent +} + +func TestGetOutgoingWebhook_Errors(t *testing.T) { + mainHelper.Parallel(t) + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // Test when webhooks are disabled + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOutgoingWebhooks = false }) + + _, err := th.App.GetOutgoingWebhook("test") + require.NotNil(t, err) + assert.Equal(t, "api.outgoing_webhook.disabled.app_error", err.Id) + + // Test getting non-existent webhook + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOutgoingWebhooks = true }) + + _, err = th.App.GetOutgoingWebhook(model.NewId()) + require.NotNil(t, err) + assert.Equal(t, http.StatusNotFound, err.StatusCode) +} + +func TestGetOutgoingWebhooksPageByUser_DisabledError(t *testing.T) { + mainHelper.Parallel(t) + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // Test when webhooks are disabled + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOutgoingWebhooks = false }) + + _, err := th.App.GetOutgoingWebhooksPageByUser(th.BasicUser.Id, 0, 10) + require.NotNil(t, err) + assert.Equal(t, "api.outgoing_webhook.disabled.app_error", err.Id) +} + +func TestRegenOutgoingWebhookToken_Error(t *testing.T) { + mainHelper.Parallel(t) + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // Test when webhooks are disabled + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOutgoingWebhooks = false }) + + hook := &model.OutgoingWebhook{Id: model.NewId()} + _, err := th.App.RegenOutgoingWebhookToken(hook) + require.NotNil(t, err) + assert.Equal(t, "api.outgoing_webhook.disabled.app_error", err.Id) +} + +func TestHandleIncomingWebhook_Errors(t *testing.T) { + mainHelper.Parallel(t) + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // Test when webhooks are disabled + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableIncomingWebhooks = false }) + + err := th.App.HandleIncomingWebhook(th.Context, "test", &model.IncomingWebhookRequest{Text: "test"}) + require.NotNil(t, err) + assert.Equal(t, "web.incoming_webhook.disabled.app_error", err.Id) + + // Test with nil request + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableIncomingWebhooks = true }) + + err = th.App.HandleIncomingWebhook(th.Context, "test", nil) + require.NotNil(t, err) + assert.Equal(t, "web.incoming_webhook.parse.app_error", err.Id) + + // Test with empty text and no attachments + err = th.App.HandleIncomingWebhook(th.Context, "test", &model.IncomingWebhookRequest{Text: ""}) + require.NotNil(t, err) + assert.Equal(t, "web.incoming_webhook.text.app_error", err.Id) + + // Test with non-existent webhook + err = th.App.HandleIncomingWebhook(th.Context, model.NewId(), &model.IncomingWebhookRequest{Text: "test"}) + require.NotNil(t, err) + assert.Equal(t, "web.incoming_webhook.invalid.app_error", err.Id) + + // Test channel locked error + hook := &model.IncomingWebhook{ + ChannelId: th.BasicChannel.Id, + TeamId: th.BasicTeam.Id, + DisplayName: "test", + Description: "test", + ChannelLocked: true, + } + webhook, err := th.App.CreateIncomingWebhookForChannel(th.BasicUser.Id, th.BasicChannel, hook) + require.Nil(t, err) + + otherChannel := th.CreateChannel(t, th.BasicTeam) + err = th.App.HandleIncomingWebhook(th.Context, webhook.Id, &model.IncomingWebhookRequest{ + Text: "test", + ChannelName: otherChannel.Name, + }) + require.NotNil(t, err) + assert.Equal(t, "web.incoming_webhook.channel_locked.app_error", err.Id) + + // Test user not found error (@mention to non-existent user) + err = th.App.HandleIncomingWebhook(th.Context, webhook.Id, &model.IncomingWebhookRequest{ + Text: "test", + ChannelName: "@nonexistentuser", + }) + require.NotNil(t, err) + assert.Equal(t, "web.incoming_webhook.user.app_error", err.Id) + + // Test channel not found error + err = th.App.HandleIncomingWebhook(th.Context, webhook.Id, &model.IncomingWebhookRequest{ + Text: "test", + ChannelName: "#nonexistentchannel", + }) + require.NotNil(t, err) + assert.Equal(t, http.StatusNotFound, err.StatusCode) +} + +func TestCreateCommandWebhook_Error(t *testing.T) { + mainHelper.Parallel(t) + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + args := &model.CommandArgs{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + RootId: "", + } + + // Create a command webhook + hook, err := th.App.CreateCommandWebhook(model.NewId(), args) + require.Nil(t, err) + require.NotNil(t, hook) + + // Test creating duplicate webhook (should work - no unique constraint) + hook2, err := th.App.CreateCommandWebhook(hook.CommandId, args) + require.Nil(t, err) + require.NotEqual(t, hook.Id, hook2.Id) +} + +func TestHandleCommandWebhook_Errors(t *testing.T) { + mainHelper.Parallel(t) + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // Test with nil response + err := th.App.HandleCommandWebhook(th.Context, "test", nil) + require.NotNil(t, err) + assert.Equal(t, "app.command_webhook.handle_command_webhook.parse", err.Id) + + // Test with non-existent webhook + response := &model.CommandResponse{Text: "test"} + err = th.App.HandleCommandWebhook(th.Context, model.NewId(), response) + require.NotNil(t, err) + assert.Equal(t, http.StatusNotFound, err.StatusCode) + + // Create a command and webhook to test with + cmd := &model.Command{ + CreatorId: th.BasicUser.Id, + TeamId: th.BasicTeam.Id, + URL: "http://nowhere.com", + Method: model.CommandMethodPost, + Trigger: "trigger", + AutoComplete: true, + AutoCompleteHint: "hint", + DisplayName: "display", + Description: "description", + } + cmd, appErr := th.App.CreateCommand(cmd) + require.Nil(t, appErr) + + args := &model.CommandArgs{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + TeamId: th.BasicTeam.Id, + RootId: "", + } + + hook, appErr := th.App.CreateCommandWebhook(cmd.Id, args) + require.Nil(t, appErr) + + // Test exceeding use limit + for i := 0; i < 5; i++ { + err = th.App.HandleCommandWebhook(th.Context, hook.Id, response) + require.Nil(t, err) + } + + // 6th call should fail + err = th.App.HandleCommandWebhook(th.Context, hook.Id, response) + require.NotNil(t, err) + assert.Equal(t, "app.command_webhook.try_use.invalid", err.Id) +} + +func TestTriggerWebhook_ErrorPaths(t *testing.T) { + mainHelper.Parallel(t) + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.EnableOutgoingWebhooks = true + *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "127.0.0.1" + // Set a very short timeout to test timeout errors + *cfg.ServiceSettings.OutgoingIntegrationRequestsTimeout = 1 + }) + + hook := &model.OutgoingWebhook{ + Id: model.NewId(), + ChannelId: th.BasicChannel.Id, + TeamId: th.BasicTeam.Id, + CallbackURLs: []string{"http://127.0.0.1:1"}, // Port 1 should fail to connect + CreatorId: th.BasicUser.Id, + TriggerWords: []string{"trigger"}, + ContentType: "application/json", + Username: "webhook-username", + IconURL: "http://example.com/icon.png", + } + + payload := &model.OutgoingWebhookPayload{ + Token: hook.Token, + TeamId: hook.TeamId, + TeamDomain: th.BasicTeam.Name, + ChannelId: th.BasicChannel.Id, + ChannelName: th.BasicChannel.Name, + Timestamp: th.BasicPost.CreateAt, + UserId: th.BasicPost.UserId, + UserName: th.BasicUser.Username, + PostId: th.BasicPost.Id, + Text: th.BasicPost.Message, + TriggerWord: "trigger", + } + + // This should complete without panic even though the webhook fails + th.App.TriggerWebhook(th.Context, payload, hook, th.BasicPost, th.BasicChannel) + + // Wait a bit to ensure the goroutine has time to fail + time.Sleep(100 * time.Millisecond) + + // Test with slow server (timeout) + slowServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(2 * time.Second) + })) + defer slowServer.Close() + + hook.CallbackURLs = []string{slowServer.URL} + th.App.TriggerWebhook(th.Context, payload, hook, th.BasicPost, th.BasicChannel) + + // Wait for timeout + time.Sleep(1500 * time.Millisecond) + + // Test with invalid JSON response + invalidJSONServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{invalid json`)) + })) + defer invalidJSONServer.Close() + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.OutgoingIntegrationRequestsTimeout = 30 + }) + + hook.CallbackURLs = []string{invalidJSONServer.URL} + th.App.TriggerWebhook(th.Context, payload, hook, th.BasicPost, th.BasicChannel) + + time.Sleep(100 * time.Millisecond) + + // Test JSON encoding error with bad content type + hook.ContentType = "unknown" + th.App.TriggerWebhook(th.Context, payload, hook, th.BasicPost, th.BasicChannel) + + time.Sleep(100 * time.Millisecond) +}