From 395abc73219d49327b16f129b381db329b0837b1 Mon Sep 17 00:00:00 2001 From: Benjamin Cooke Date: Fri, 18 Oct 2024 11:01:13 -0400 Subject: [PATCH 01/17] move google drive calls to its own package and create a wrapper for it --- go.mod | 1 + go.sum | 2 + server/plugin/api.go | 102 ++++++------ server/plugin/create.go | 22 +-- server/plugin/google/drive.go | 231 ++++++++++++++++++++++++++ server/plugin/google/google.go | 82 +++++++++ server/plugin/kvstore/google_drive.go | 45 +++++ server/plugin/kvstore/kvstore.go | 6 + server/plugin/model/google.go | 17 ++ server/plugin/notifications.go | 59 +++---- server/plugin/plugin.go | 5 + 11 files changed, 470 insertions(+), 102 deletions(-) create mode 100644 server/plugin/google/drive.go create mode 100644 server/plugin/google/google.go create mode 100644 server/plugin/model/google.go diff --git a/go.mod b/go.mod index a7b7a34..60a6551 100644 --- a/go.mod +++ b/go.mod @@ -70,6 +70,7 @@ require ( golang.org/x/crypto v0.24.0 // indirect golang.org/x/net v0.26.0 // indirect golang.org/x/sys v0.21.0 // indirect + golang.org/x/time v0.7.0 google.golang.org/genproto/googleapis/rpc v0.0.0-20240610135401-a8a62080eff3 // indirect google.golang.org/grpc v1.64.0 // indirect google.golang.org/protobuf v1.34.2 // indirect diff --git a/go.sum b/go.sum index 5a79cde..a838f13 100644 --- a/go.sum +++ b/go.sum @@ -336,6 +336,8 @@ golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= +golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20181030000716-a0a13e073c7b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/server/plugin/api.go b/server/plugin/api.go index 26068fa..8018dca 100644 --- a/server/plugin/api.go +++ b/server/plugin/api.go @@ -1,7 +1,6 @@ package plugin import ( - "bytes" "context" "encoding/json" "fmt" @@ -14,6 +13,7 @@ import ( "github.com/gorilla/mux" mattermostModel "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/pluginapi" "github.com/mattermost/mattermost/server/public/pluginapi/cluster" "github.com/mattermost/mattermost/server/public/pluginapi/experimental/bot/logger" "github.com/mattermost/mattermost/server/public/pluginapi/experimental/flow" @@ -384,9 +384,26 @@ func (p *Plugin) handleFileCreation(c *Context, w http.ResponseWriter, r *http.R return } - ctx := context.Background() + driveService, driveServiceError := p.GoogleClient.NewDriveService(c.Ctx, c.UserID) + if driveServiceError != nil { + p.API.LogError("Failed to create Google Drive client", "err", driveServiceError) + p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusInternalServerError}) + return + } + for i := 0; i < 11; i++ { + _, aboutErr := driveService.About(c.Ctx, "*") + if aboutErr != nil { + p.API.LogError("Failed to get Google Drive about", "err", aboutErr) + p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusInternalServerError}) + return + } + } + + if driveService != nil { + return + } conf := p.getOAuthConfig() - authToken, err := p.getGoogleUserToken(request.UserId) + authToken, err := p.getGoogleUserToken(c.UserID) if err != nil { p.API.LogError("Failed to get Google user token", "err", err) p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusInternalServerError}) @@ -399,7 +416,7 @@ func (p *Plugin) handleFileCreation(c *Context, w http.ResponseWriter, r *http.R switch fileType { case "doc": { - srv, dErr := docs.NewService(ctx, option.WithTokenSource(conf.TokenSource(ctx, authToken))) + srv, dErr := docs.NewService(c.Ctx, option.WithTokenSource(conf.TokenSource(c.Ctx, authToken))) if dErr != nil { p.API.LogError("Failed to create Google Docs client", "err", dErr) p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusInternalServerError}) @@ -416,7 +433,7 @@ func (p *Plugin) handleFileCreation(c *Context, w http.ResponseWriter, r *http.R } case "slide": { - srv, dErr := slides.NewService(ctx, option.WithTokenSource(conf.TokenSource(ctx, authToken))) + srv, dErr := slides.NewService(c.Ctx, option.WithTokenSource(conf.TokenSource(c.Ctx, authToken))) if dErr != nil { p.API.LogError("Failed to create Google Slides client", "err", dErr) p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusInternalServerError}) @@ -433,7 +450,7 @@ func (p *Plugin) handleFileCreation(c *Context, w http.ResponseWriter, r *http.R } case "sheet": { - srv, dErr := sheets.NewService(ctx, option.WithTokenSource(conf.TokenSource(ctx, authToken))) + srv, dErr := sheets.NewService(c.Ctx, option.WithTokenSource(conf.TokenSource(c.Ctx, authToken))) if dErr != nil { p.API.LogError("Failed to create Google Sheets client", "err", dErr) p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusInternalServerError}) @@ -458,13 +475,13 @@ func (p *Plugin) handleFileCreation(c *Context, w http.ResponseWriter, r *http.R return } - err = p.handleFilePermissions(request.UserId, createdFileID, fileCreationParams.FileAccess, request.ChannelId) + err = p.handleFilePermissions(c.Ctx, c.UserID, createdFileID, fileCreationParams.FileAccess, request.ChannelId) if err != nil { p.API.LogError("Failed to modify file permissions", "err", err) p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusInternalServerError}) return } - err = p.sendFileCreatedMessage(request.ChannelId, createdFileID, request.UserId, fileCreationParams.Message, fileCreationParams.ShareInChannel, authToken) + err = p.sendFileCreatedMessage(c.Ctx, request.ChannelId, createdFileID, c.UserID, fileCreationParams.Message, fileCreationParams.ShareInChannel) if err != nil { p.API.LogError("Failed to send file creation post", "err", err) p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusInternalServerError}) @@ -476,6 +493,7 @@ func (p *Plugin) handleDriveWatchNotifications(c *Context, w http.ResponseWriter resourceState := r.Header.Get("X-Goog-Resource-State") userID := r.URL.Query().Get("userID") + _, _ = p.Client.KV.Set("userID-"+userID, userID, pluginapi.SetExpiry(20)) if resourceState != "change" { w.WriteHeader(http.StatusOK) return @@ -502,7 +520,7 @@ func (p *Plugin) handleDriveWatchNotifications(c *Context, w http.ResponseWriter return } - srv, err := drive.NewService(context.Background(), option.WithTokenSource(conf.TokenSource(context.Background(), authToken))) + driveService, err := p.GoogleClient.NewDriveService(c.Ctx, userID) if err != nil { p.API.LogError("Failed to create Google Drive service", "err", err, "userID", userID) w.WriteHeader(http.StatusInternalServerError) @@ -535,7 +553,7 @@ func (p *Plugin) handleDriveWatchNotifications(c *Context, w http.ResponseWriter pageToken := watchChannelData.PageToken if pageToken == "" { // This is to catch any edge cases where the pageToken is not set. - tokenResponse, tokenErr := srv.Changes.GetStartPageToken().Do() + tokenResponse, tokenErr := driveService.GetStartPageToken(c.Ctx) if tokenErr != nil { p.API.LogError("Failed to get start page token", "err", tokenErr, "userID", userID) w.WriteHeader(http.StatusInternalServerError) @@ -547,7 +565,7 @@ func (p *Plugin) handleDriveWatchNotifications(c *Context, w http.ResponseWriter var pageTokenErr error var changes []*drive.Change for { - changeList, changeErr := srv.Changes.List(pageToken).Fields("*").Do() + changeList, changeErr := driveService.ChangesList(c.Ctx, pageToken) if changeErr != nil { p.API.LogError("Failed to fetch Google Drive changes", "err", changeErr, "userID", userID) w.WriteHeader(http.StatusInternalServerError) @@ -653,7 +671,7 @@ func (p *Plugin) handleDriveWatchNotifications(c *Context, w http.ResponseWriter if len(activity.Actors) > 0 && activity.Actors[0].User != nil && activity.Actors[0].User.KnownUser != nil && activity.Actors[0].User.KnownUser.IsCurrentUser { continue } - p.handleCommentNotifications(srv, change.File, userID, activity) + p.handleCommentNotifications(c.Ctx, driveService, change.File, userID, activity) } if activity.PrimaryActionDetail.PermissionChange != nil { if activity.Timestamp > lastActivityTime { @@ -742,22 +760,15 @@ func (p *Plugin) handleCommentReplyDialog(c *Context, w http.ResponseWriter, r * commentID := r.URL.Query().Get("commentID") fileID := r.URL.Query().Get("fileID") - conf := p.getOAuthConfig() - authToken, err := p.getGoogleUserToken(request.UserId) + driveService, err := p.GoogleClient.NewDriveService(c.Ctx, c.UserID) if err != nil { - p.API.LogError("Failed to get Google user token", "err", err) + p.API.LogError("Failed to create Google Drive service", "err", err, "userID", c.UserID) p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusInternalServerError}) return } - srv, err := drive.NewService(context.Background(), option.WithTokenSource(conf.TokenSource(context.Background(), authToken))) - if err != nil { - p.API.LogError("Failed to create Google Drive service", "err", err) - p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusInternalServerError}) - return - } - reply, err := srv.Replies.Create(fileID, commentID, &drive.Reply{ + reply, err := driveService.CreateReply(c.Ctx, fileID, commentID, &drive.Reply{ Content: request.Submission["message"].(string), - }).Fields("*").Do() + }) if err != nil { p.API.LogError("Failed to create comment reply", "err", err) p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusInternalServerError}) @@ -803,25 +814,16 @@ func (p *Plugin) handleFileUpload(c *Context, w http.ResponseWriter, r *http.Req return } - ctx := context.Background() - conf := p.getOAuthConfig() - authToken, err := p.getGoogleUserToken(c.UserID) + driveService, err := p.GoogleClient.NewDriveService(c.Ctx, c.UserID) if err != nil { - p.API.LogError("Failed to get Google user token", "err", err) + p.API.LogError("Failed to create Google Drive service", "err", err, "userID", c.UserID) p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusInternalServerError}) return } - srv, err := drive.NewService(ctx, option.WithTokenSource(conf.TokenSource(ctx, authToken))) - if err != nil { - p.API.LogError("Failed to create Google Drive service", "err", err) - p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusInternalServerError}) - return - } - - _, err = srv.Files.Create(&drive.File{ + _, err = driveService.CreateFile(c.Ctx, &drive.File{ Name: fileInfo.Name, - }).Media(bytes.NewReader(fileReader)).Do() + }, fileReader) if err != nil { p.API.LogError("Failed to upload file", "err", err) p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusInternalServerError}) @@ -853,19 +855,9 @@ func (p *Plugin) handleAllFilesUpload(c *Context, w http.ResponseWriter, r *http return } - ctx := context.Background() - conf := p.getOAuthConfig() - - authToken, err := p.getGoogleUserToken(c.UserID) - if err != nil { - p.API.LogError("Failed to get Google user token", "err", err) - p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusInternalServerError}) - return - } - - srv, err := drive.NewService(ctx, option.WithTokenSource(conf.TokenSource(ctx, authToken))) + driveService, err := p.GoogleClient.NewDriveService(c.Ctx, c.UserID) if err != nil { - p.API.LogError("Failed to create Google Drive service", "err", err) + p.API.LogError("Failed to create Google Drive service", "err", err, "userID", c.UserID) p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusInternalServerError}) return } @@ -886,9 +878,9 @@ func (p *Plugin) handleAllFilesUpload(c *Context, w http.ResponseWriter, r *http return } - _, err := srv.Files.Create(&drive.File{ + _, err = driveService.CreateFile(c.Ctx, &drive.File{ Name: fileInfo.Name, - }).Media(bytes.NewReader(fileReader)).Do() + }, fileReader) if err != nil { p.API.LogError("Failed to upload file", "err", err) p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusInternalServerError}) @@ -918,13 +910,13 @@ func (p *Plugin) initializeAPI() { oauthRouter.HandleFunc("/connect", p.checkAuth(p.attachContext(p.connectUserToGoogle), ResponseTypePlain)).Methods(http.MethodGet) oauthRouter.HandleFunc("/complete", p.checkAuth(p.attachContext(p.completeConnectUserToGoogle), ResponseTypePlain)).Methods(http.MethodGet) - apiRouter.HandleFunc("/create", p.attachContext(p.handleFileCreation)).Methods(http.MethodPost) + apiRouter.HandleFunc("/create", p.checkAuth(p.attachContext(p.handleFileCreation), ResponseTypeJSON)).Methods(http.MethodPost) apiRouter.HandleFunc("/webhook", p.attachContext(p.handleDriveWatchNotifications)).Methods(http.MethodPost) - apiRouter.HandleFunc("/reply_dialog", p.attachContext(p.openCommentReplyDialog)).Methods(http.MethodPost) - apiRouter.HandleFunc("/reply", p.attachContext(p.handleCommentReplyDialog)).Methods(http.MethodPost) + apiRouter.HandleFunc("/reply_dialog", p.checkAuth(p.attachContext(p.openCommentReplyDialog), ResponseTypeJSON)).Methods(http.MethodPost) + apiRouter.HandleFunc("/reply", p.checkAuth(p.attachContext(p.handleCommentReplyDialog), ResponseTypeJSON)).Methods(http.MethodPost) - apiRouter.HandleFunc("/upload_file", p.attachContext(p.handleFileUpload)).Methods(http.MethodPost) - apiRouter.HandleFunc("/upload_all", p.attachContext(p.handleAllFilesUpload)).Methods(http.MethodPost) + apiRouter.HandleFunc("/upload_file", p.checkAuth(p.attachContext(p.handleFileUpload), ResponseTypeJSON)).Methods(http.MethodPost) + apiRouter.HandleFunc("/upload_all", p.checkAuth(p.attachContext(p.handleAllFilesUpload), ResponseTypeJSON)).Methods(http.MethodPost) } diff --git a/server/plugin/create.go b/server/plugin/create.go index b01b2f2..7613283 100644 --- a/server/plugin/create.go +++ b/server/plugin/create.go @@ -9,22 +9,18 @@ import ( "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/plugin" - "golang.org/x/oauth2" "golang.org/x/text/cases" "golang.org/x/text/language" "google.golang.org/api/drive/v3" - "google.golang.org/api/option" ) -func (p *Plugin) sendFileCreatedMessage(channelID, fileID, userID, message string, shareInChannel bool, authToken *oauth2.Token) error { - ctx := context.Background() - conf := p.getOAuthConfig() - srv, err := drive.NewService(ctx, option.WithTokenSource(conf.TokenSource(ctx, authToken))) +func (p *Plugin) sendFileCreatedMessage(ctx context.Context, channelID, fileID, userID, message string, shareInChannel bool) error { + driveService, err := p.GoogleClient.NewDriveService(ctx, userID) if err != nil { - p.API.LogError("Failed to create Google Drive client", "err", err) + p.API.LogError("Failed to create Google Drive service", "err", err, "userID", userID) return err } - file, err := srv.Files.Get(fileID).Fields("webViewLink", "id", "owners", "permissions", "name", "iconLink", "thumbnailLink", "createdTime").Do() + file, err := driveService.GetFile(ctx, fileID) if err != nil { p.API.LogError("Failed to fetch file", "err", err, "fileID", fileID) return err @@ -66,7 +62,7 @@ func (p *Plugin) sendFileCreatedMessage(channelID, fileID, userID, message strin return nil } -func (p *Plugin) handleFilePermissions(userID string, fileID string, fileAccess string, channelID string) error { +func (p *Plugin) handleFilePermissions(ctx context.Context, userID string, fileID string, fileAccess string, channelID string) error { permissions := make([]*drive.Permission, 0) switch fileAccess { case "all_view": @@ -125,18 +121,14 @@ func (p *Plugin) handleFilePermissions(userID string, fileID string, fileAccess } } - ctx := context.Background() - conf := p.getOAuthConfig() - - authToken, _ := p.getGoogleUserToken(userID) - srv, err := drive.NewService(ctx, option.WithTokenSource(conf.TokenSource(ctx, authToken))) + driveService, err := p.GoogleClient.NewDriveService(ctx, userID) if err != nil { p.API.LogError("Failed to create Google Drive client", "err", err) return err } for _, permission := range permissions { - _, err := srv.Permissions.Create(fileID, permission).Do() + _, err := driveService.CreatePermission(ctx, fileID, permission) if err != nil { p.API.LogError("Something went wrong while updating permissions for file", "err", err, "fileID", fileID) return err diff --git a/server/plugin/google/drive.go b/server/plugin/google/drive.go new file mode 100644 index 0000000..3966f3a --- /dev/null +++ b/server/plugin/google/drive.go @@ -0,0 +1,231 @@ +package google + +import ( + "bytes" + "context" + "encoding/json" + "errors" + + "github.com/mattermost/mattermost/server/public/plugin" + + "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/kvstore" + "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/model" + + "golang.org/x/time/rate" + "google.golang.org/api/drive/v3" + "google.golang.org/api/googleapi" +) + +type DriveService struct { + service *drive.Service + limiter *rate.Limiter + papi plugin.API + userID string + kvstore kvstore.KVStore +} + +func (ds DriveService) parseGoogleErrors(err error) { + if googleErr, ok := err.(*googleapi.Error); ok { + reason := "" + if len(googleErr.Errors) > 0 { + for _, error := range googleErr.Errors { + if error.Reason != "" { + reason = error.Reason + break + } + } + } + if reason == "userRateLimitExceeded" { + err = ds.kvstore.StoreUserRateLimitExceeded(ds.userID) + if err != nil { + ds.papi.LogError("Failed to store user rate limit exceeded", "userID", ds.userID, "err", err) + return + } + } + if reason == "rateLimitExceeded" && len(googleErr.Details) > 0 { + for _, detail := range googleErr.Details { + byteData, _ := json.Marshal(detail) + var errDetail *model.ErrorDetail + jsonErr := json.Unmarshal(byteData, &errDetail) + if jsonErr != nil { + ds.papi.LogError("Failed to parse error details", "err", jsonErr) + continue + } + + if errDetail != nil { + // Even if the original "reason" is rateLimitExceeded, we need to check the QuotaLimit field in the metadata because it might only apply to this specific user. + if errDetail.Reason == "RATE_LIMIT_EXCEEDED" && errDetail.Metadata.QuotaLimit == "defaultPerMinutePerUser" { + err = ds.kvstore.StoreUserRateLimitExceeded(ds.userID) + if err != nil { + ds.papi.LogError("Failed to store user rate limit exceeded", "userID", ds.userID, "err", err) + return + } + } else if errDetail.Reason == "RATE_LIMIT_EXCEEDED" && errDetail.Metadata.QuotaLimit == "defaultPerMinutePerProject" { + err = ds.kvstore.StoreProjectRateLimitExceeded() + if err != nil { + ds.papi.LogError("Failed to store rate limit exceeded", "err", err) + return + } + } + } + } + } + } +} + +func (ds DriveService) checkRateLimits(ctx context.Context) error { + userIsRateLimited, err := ds.kvstore.GetUserRateLimitExceeded(ds.userID) + if err != nil { + return err + } + if userIsRateLimited { + return errors.New("user rate limit exceeded") + } + + projectIsRateLimited, err := ds.kvstore.GetProjectRateLimitExceeded() + if err != nil { + return err + } + if projectIsRateLimited { + return errors.New("project rate limit exceeded") + } + + err = ds.limiter.WaitN(ctx, 1) + if err != nil { + return err + } + + return nil +} + +func (ds DriveService) About(ctx context.Context, fields googleapi.Field) (*drive.About, error) { + err := ds.checkRateLimits(ctx) + if err != nil { + return nil, err + } + + da, err := ds.service.About.Get().Fields(fields).Do() + if err != nil { + ds.parseGoogleErrors(err) + return nil, err + } + return da, nil +} + +func (ds DriveService) WatchChannel(ctx context.Context, startPageToken *drive.StartPageToken, requestChannel *drive.Channel) (*drive.Channel, error) { + err := ds.checkRateLimits(ctx) + if err != nil { + return nil, err + } + + da, err := ds.service.Changes.Watch(startPageToken.StartPageToken, requestChannel).Do() + if err != nil { + ds.parseGoogleErrors(err) + return nil, err + } + return da, nil +} + +func (ds DriveService) StopChannel(ctx context.Context, channel *drive.Channel) error { + err := ds.checkRateLimits(ctx) + if err != nil { + return err + } + err = ds.service.Channels.Stop(channel).Do() + if err != nil { + ds.parseGoogleErrors(err) + return err + } + return nil +} + +func (ds DriveService) ChangesList(ctx context.Context, pageToken string) (*drive.ChangeList, error) { + err := ds.checkRateLimits(ctx) + if err != nil { + return nil, err + } + changes, err := ds.service.Changes.List(pageToken).Fields("*").Do() + if err != nil { + ds.parseGoogleErrors(err) + return nil, err + } + return changes, nil +} + +func (ds DriveService) GetStartPageToken(ctx context.Context) (*drive.StartPageToken, error) { + err := ds.checkRateLimits(ctx) + if err != nil { + return nil, err + } + tokenResponse, err := ds.service.Changes.GetStartPageToken().Do() + if err != nil { + ds.parseGoogleErrors(err) + return nil, err + } + return tokenResponse, nil +} + +func (ds DriveService) GetComments(ctx context.Context, fileID string, commentID string) (*drive.Comment, error) { + err := ds.checkRateLimits(ctx) + if err != nil { + return nil, err + } + comment, err := ds.service.Comments.Get(fileID, commentID).Fields("*").IncludeDeleted(true).Do() + if err != nil { + ds.parseGoogleErrors(err) + return nil, err + } + return comment, nil +} + +func (ds DriveService) CreateReply(ctx context.Context, fileID string, commentID string, reply *drive.Reply) (*drive.Reply, error) { + err := ds.checkRateLimits(ctx) + if err != nil { + return nil, err + } + googleReply, err := ds.service.Replies.Create(fileID, commentID, reply).Fields("*").Do() + if err != nil { + ds.parseGoogleErrors(err) + return nil, err + } + return googleReply, nil +} + +func (ds DriveService) CreateFile(ctx context.Context, file *drive.File, fileReader []byte) (*drive.File, error) { + err := ds.checkRateLimits(ctx) + if err != nil { + return nil, err + } + googleFile, err := ds.service.Files.Create(file).Media(bytes.NewReader(fileReader)).Do() + if err != nil { + ds.parseGoogleErrors(err) + return nil, err + } + return googleFile, nil +} + +func (ds DriveService) GetFile(ctx context.Context, fileID string) (*drive.File, error) { + err := ds.checkRateLimits(ctx) + if err != nil { + return nil, err + } + file, err := ds.service.Files.Get(fileID).Fields("*").Do() + if err != nil { + ds.parseGoogleErrors(err) + return nil, err + } + return file, nil +} + +func (ds DriveService) CreatePermission(ctx context.Context, fileID string, permission *drive.Permission) (*drive.Permission, error) { + err := ds.checkRateLimits(ctx) + if err != nil { + return nil, err + } + googlePermission, err := ds.service.Permissions.Create(fileID, permission).Do() + if err != nil { + ds.parseGoogleErrors(err) + return nil, err + } + return googlePermission, nil +} diff --git a/server/plugin/google/google.go b/server/plugin/google/google.go new file mode 100644 index 0000000..f805f4e --- /dev/null +++ b/server/plugin/google/google.go @@ -0,0 +1,82 @@ +package google + +import ( + "context" + "encoding/json" + "time" + + "github.com/mattermost/mattermost/server/public/plugin" + "golang.org/x/oauth2" + "golang.org/x/time/rate" + "google.golang.org/api/drive/v3" + "google.golang.org/api/option" + + "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/config" + "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/kvstore" + "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/utils" +) + +type Client struct { + oauthConfig *oauth2.Config + config *config.Configuration + kvstore kvstore.KVStore + papi plugin.API + limiter *rate.Limiter +} + +func NewGoogleClient(oauthConfig *oauth2.Config, config *config.Configuration, kvstore kvstore.KVStore, papi plugin.API) *Client { + return &Client{ + oauthConfig: oauthConfig, + config: config, + kvstore: kvstore, + papi: papi, + limiter: rate.NewLimiter(rate.Every(time.Second), 10), + } +} + +func (g *Client) NewDriveService(ctx context.Context, userID string) (*DriveService, error) { + authToken, err := g.getGoogleUserToken(userID) + if err != nil { + return nil, err + } + if !g.limiter.Allow() { + err = g.limiter.WaitN(ctx, 1) + if err != nil { + return nil, err + } + } + + srv, err := drive.NewService(ctx, option.WithTokenSource(g.oauthConfig.TokenSource(ctx, authToken))) + if err != nil { + return nil, err + } + + return &DriveService{ + service: srv, + papi: g.papi, + limiter: g.limiter, + userID: userID, + kvstore: g.kvstore, + }, nil +} + +func (g *Client) getGoogleUserToken(userID string) (*oauth2.Token, error) { + encryptedToken, err := g.kvstore.GetGoogleUserToken(userID) + if err != nil { + return nil, err + } + + if len(encryptedToken) == 0 { + return nil, nil + } + + decryptedToken, err := utils.Decrypt([]byte(g.config.EncryptionKey), string(encryptedToken)) + if err != nil { + return nil, err + } + + var oauthToken oauth2.Token + err = json.Unmarshal([]byte(decryptedToken), &oauthToken) + + return &oauthToken, err +} diff --git a/server/plugin/kvstore/google_drive.go b/server/plugin/kvstore/google_drive.go index e93eebb..057c88d 100644 --- a/server/plugin/kvstore/google_drive.go +++ b/server/plugin/kvstore/google_drive.go @@ -2,6 +2,7 @@ package kvstore import ( "fmt" + "time" "github.com/pkg/errors" @@ -32,6 +33,10 @@ func getLastActivityKey(userID, fileID string) string { return fmt.Sprintf("last_activity-%s-%s", userID, fileID) } +func getRateLimitKey(userID string) string { + return fmt.Sprintf("rate_limit_exceeded-%s", userID) +} + func (kv Impl) GetWatchChannelData(userID string) (*model.WatchChannelData, error) { var watchChannelData model.WatchChannelData @@ -145,3 +150,43 @@ func (kv Impl) DeleteGoogleUserToken(userID string) error { } return nil } + +func (kv Impl) StoreUserRateLimitExceeded(userID string) error { + saved, err := kv.client.KV.Set(getRateLimitKey(userID), []byte("true"), pluginapi.SetExpiry(time.Second*10)) + if !saved && err != nil { + return errors.Wrap(err, "database error occurred when trying to save user rate limit exceeded") + } else if !saved && err == nil { + return errors.New("Failed to save user rate limit exceeded") + } + return nil +} + +func (kv Impl) GetUserRateLimitExceeded(userID string) (bool, error) { + var rateLimitExceeded bool + + err := kv.client.KV.Get(getRateLimitKey(userID), &rateLimitExceeded) + if err != nil { + return false, errors.Wrap(err, "failed to get user rate limit exceeded") + } + return rateLimitExceeded, nil +} + +func (kv Impl) StoreProjectRateLimitExceeded() error { + saved, err := kv.client.KV.Set("project_rate_limit_exceeded", []byte("true"), pluginapi.SetExpiry(time.Second*10)) + if !saved && err != nil { + return errors.Wrap(err, "database error occurred when trying to save project rate limit exceeded") + } else if !saved && err == nil { + return errors.New("Failed to save project rate limit exceeded") + } + return nil +} + +func (kv Impl) GetProjectRateLimitExceeded() (bool, error) { + var rateLimitExceeded bool + + err := kv.client.KV.Get("project_rate_limit_exceeded", &rateLimitExceeded) + if err != nil { + return false, errors.Wrap(err, "failed to get project rate limit exceeded") + } + return rateLimitExceeded, nil +} diff --git a/server/plugin/kvstore/kvstore.go b/server/plugin/kvstore/kvstore.go index fb99eaa..a514293 100644 --- a/server/plugin/kvstore/kvstore.go +++ b/server/plugin/kvstore/kvstore.go @@ -18,4 +18,10 @@ type KVStore interface { StoreGoogleUserToken(userID, encryptedToken string) error GetGoogleUserToken(userID string) ([]byte, error) DeleteGoogleUserToken(userID string) error + + StoreUserRateLimitExceeded(userID string) error + GetUserRateLimitExceeded(userID string) (bool, error) + + StoreProjectRateLimitExceeded() error + GetProjectRateLimitExceeded() (bool, error) } diff --git a/server/plugin/model/google.go b/server/plugin/model/google.go new file mode 100644 index 0000000..9dce17c --- /dev/null +++ b/server/plugin/model/google.go @@ -0,0 +1,17 @@ +package model + +type ErrorMetadata struct { + Consumer string `json:"consumer"` + QuotaLimit string `json:"quota_limit"` + QuotaLimitValue string `json:"quota_limit_value"` + QuotaLocation string `json:"quota_location"` + QuotaMetric string `json:"quota_metric"` + Service string `json:"service"` +} + +type ErrorDetail struct { + DetailType string `json:"@type"` + Domain string `json:"domain"` + Metadata ErrorMetadata `json:"metadata"` + Reason string `json:"reason"` +} diff --git a/server/plugin/notifications.go b/server/plugin/notifications.go index 13292cd..73bbd2b 100644 --- a/server/plugin/notifications.go +++ b/server/plugin/notifications.go @@ -12,13 +12,13 @@ import ( "github.com/mattermost/mattermost/server/public/plugin" "google.golang.org/api/drive/v3" "google.golang.org/api/driveactivity/v2" - "google.golang.org/api/option" + "github.com/darkLord19/mattermost-plugin-google-drive/server/plugin/google" "github.com/darkLord19/mattermost-plugin-google-drive/server/plugin/model" "github.com/darkLord19/mattermost-plugin-google-drive/server/plugin/utils" ) -func (p *Plugin) handleAddedComment(dSrv *drive.Service, fileID, userID string, activity *driveactivity.DriveActivity, file *drive.File) { +func (p *Plugin) handleAddedComment(ctx context.Context, dSrv *google.DriveService, fileID, userID string, activity *driveactivity.DriveActivity, file *drive.File) { if len(activity.Targets) == 0 || activity.Targets[0].FileComment == nil || activity.Targets[0].FileComment.LegacyCommentId == "" { @@ -26,7 +26,7 @@ func (p *Plugin) handleAddedComment(dSrv *drive.Service, fileID, userID string, return } commentID := activity.Targets[0].FileComment.LegacyDiscussionId - comment, err := dSrv.Comments.Get(fileID, commentID).Fields("*").IncludeDeleted(true).Do() + comment, err := dSrv.GetComments(ctx, fileID, commentID) if err != nil { p.API.LogError("Failed to get comment by legacyDiscussionId", "err", err, "commentID", commentID) return @@ -64,7 +64,7 @@ func (p *Plugin) handleDeletedComment(userID string, activity *driveactivity.Dri p.createBotDMPost(userID, message, nil) } -func (p *Plugin) handleReplyAdded(dSrv *drive.Service, fileID, userID string, activity *driveactivity.DriveActivity, file *drive.File) { +func (p *Plugin) handleReplyAdded(ctx context.Context, dSrv *google.DriveService, fileID, userID string, activity *driveactivity.DriveActivity, file *drive.File) { if len(activity.Targets) == 0 || activity.Targets[0].FileComment == nil || activity.Targets[0].FileComment.LegacyDiscussionId == "" { @@ -72,7 +72,7 @@ func (p *Plugin) handleReplyAdded(dSrv *drive.Service, fileID, userID string, ac return } commentID := activity.Targets[0].FileComment.LegacyDiscussionId - comment, err := dSrv.Comments.Get(fileID, commentID).Fields("*").IncludeDeleted(true).Do() + comment, err := dSrv.GetComments(ctx, fileID, commentID) if err != nil { p.API.LogError("Failed to get comment by legacyDiscussionId", "err", err, "commentID", commentID) return @@ -119,7 +119,7 @@ func (p *Plugin) handleReplyDeleted(userID string, activity *driveactivity.Drive p.createBotDMPost(userID, message, nil) } -func (p *Plugin) handleResolvedComment(dSrv *drive.Service, fileID, userID string, activity *driveactivity.DriveActivity, file *drive.File) { +func (p *Plugin) handleResolvedComment(ctx context.Context, dSrv *google.DriveService, fileID, userID string, activity *driveactivity.DriveActivity, file *drive.File) { if len(activity.Targets) == 0 || activity.Targets[0].FileComment == nil || activity.Targets[0].FileComment.LegacyCommentId == "" { @@ -127,7 +127,7 @@ func (p *Plugin) handleResolvedComment(dSrv *drive.Service, fileID, userID strin return } commentID := activity.Targets[0].FileComment.LegacyCommentId - comment, err := dSrv.Comments.Get(fileID, commentID).Fields("*").IncludeDeleted(true).Do() + comment, err := dSrv.GetComments(ctx, fileID, commentID) if err != nil { p.API.LogError("Failed to get comment by legacyCommentId", "err", err, "commentID", commentID) return @@ -137,7 +137,7 @@ func (p *Plugin) handleResolvedComment(dSrv *drive.Service, fileID, userID strin p.createBotDMPost(userID, message, nil) } -func (p *Plugin) handleReopenedComment(dSrv *drive.Service, fileID, userID string, activity *driveactivity.DriveActivity, file *drive.File) { +func (p *Plugin) handleReopenedComment(ctx context.Context, dSrv *google.DriveService, fileID, userID string, activity *driveactivity.DriveActivity, file *drive.File) { if len(activity.Targets) == 0 || activity.Targets[0].FileComment == nil || activity.Targets[0].FileComment.LegacyDiscussionId == "" { @@ -145,7 +145,7 @@ func (p *Plugin) handleReopenedComment(dSrv *drive.Service, fileID, userID strin return } commentID := activity.Targets[0].FileComment.LegacyDiscussionId - comment, err := dSrv.Comments.Get(fileID, commentID).Fields("*").IncludeDeleted(true).Do() + comment, err := dSrv.GetComments(ctx, fileID, commentID) if err != nil { p.API.LogError("Failed to get comment by legacyDiscussionId", "err", err, "commentID", commentID) return @@ -161,7 +161,7 @@ func (p *Plugin) handleSuggestionReplyAdded(userID string, activity *driveactivi p.createBotDMPost(userID, message, nil) } -func (p *Plugin) handleCommentNotifications(dSrv *drive.Service, file *drive.File, userID string, activity *driveactivity.DriveActivity) { +func (p *Plugin) handleCommentNotifications(ctx context.Context, dSrv *google.DriveService, file *drive.File, userID string, activity *driveactivity.DriveActivity) { fileID := file.Id if ok := activity.PrimaryActionDetail.Comment.Post != nil; !ok { @@ -171,17 +171,17 @@ func (p *Plugin) handleCommentNotifications(dSrv *drive.Service, file *drive.Fil switch postSubType { case "ADDED": - p.handleAddedComment(dSrv, fileID, userID, activity, file) + p.handleAddedComment(ctx, dSrv, fileID, userID, activity, file) case "DELETED": p.handleDeletedComment(userID, activity, file) case "REPLY_ADDED": - p.handleReplyAdded(dSrv, fileID, userID, activity, file) + p.handleReplyAdded(ctx, dSrv, fileID, userID, activity, file) case "REPLY_DELETED": p.handleReplyDeleted(userID, activity, file) case "RESOLVED": - p.handleResolvedComment(dSrv, fileID, userID, activity, file) + p.handleResolvedComment(ctx, dSrv, fileID, userID, activity, file) case "REOPENED": - p.handleReopenedComment(dSrv, fileID, userID, activity, file) + p.handleReopenedComment(ctx, dSrv, fileID, userID, activity, file) } suggestion := activity.PrimaryActionDetail.Comment.Suggestion @@ -210,20 +210,13 @@ func (p *Plugin) handleFileSharedNotification(file *drive.File, userID string) { func (p *Plugin) startDriveWatchChannel(userID string) error { ctx := context.Background() - conf := p.getOAuthConfig() - authToken, err := p.getGoogleUserToken(userID) + driveService, err := p.GoogleClient.NewDriveService(ctx, userID) if err != nil { - p.API.LogError("Failed to get auth token", "err", err) + p.API.LogError("Failed to create Google Drive service", "err", err, "userID", userID) return err } - srv, err := drive.NewService(ctx, option.WithTokenSource(conf.TokenSource(ctx, authToken))) - if err != nil { - p.API.LogError("Failed to create Google Drive client", "err", err) - return err - } - - startPageToken, err := srv.Changes.GetStartPageToken().Do() + startPageToken, err := driveService.GetStartPageToken(ctx) if err != nil { p.API.LogError("Failed to get start page token", "err", err) return err @@ -252,7 +245,7 @@ func (p *Plugin) startDriveWatchChannel(userID string) error { }, } - channel, err := srv.Changes.Watch(startPageToken.StartPageToken, &requestChannel).Do() + channel, err := driveService.WatchChannel(ctx, startPageToken, &requestChannel) if err != nil { p.API.LogError("Failed to register watch on drive", "err", err, "requestChannel", requestChannel) return err @@ -297,6 +290,7 @@ func (p *Plugin) startDriveActivityNotifications(userID string) string { } func (p *Plugin) stopDriveActivityNotifications(userID string) string { + ctx := context.Background() watchChannelData, err := p.KVStore.GetWatchChannelData(userID) if err != nil { p.API.LogError("Failed to get Google Drive change channel data", "userID", userID) @@ -307,10 +301,11 @@ func (p *Plugin) stopDriveActivityNotifications(userID string) string { return "Google Drive activity notifications are not enabled for you." } - ctx := context.Background() - conf := p.getOAuthConfig() - authToken, _ := p.getGoogleUserToken(userID) - srv, _ := drive.NewService(ctx, option.WithTokenSource(conf.TokenSource(ctx, authToken))) + driveService, err := p.GoogleClient.NewDriveService(ctx, userID) + if err != nil { + p.API.LogError("Failed to create Google Drive service", "err", err, "userID", userID) + return "Something went wrong while stopping Google Drive activity notifications. Please contact your organization admin for support." + } err = p.KVStore.DeleteWatchChannelData(userID) if err != nil { @@ -318,13 +313,13 @@ func (p *Plugin) stopDriveActivityNotifications(userID string) string { return "Something went wrong while stopping Google Drive activity notifications. Please contact your organization admin for support." } - err = srv.Channels.Stop(&drive.Channel{ + err = driveService.StopChannel(ctx, &drive.Channel{ Id: watchChannelData.ChannelID, ResourceId: watchChannelData.ResourceID, - }).Do() - + }) if err != nil { p.API.LogError("Failed to stop Google Drive change channel", "err", err) + return "Something went wrong while stopping Google Drive activity notifications. Please contact your organization admin for support." } return "Successfully disabled Google Drive activity notifications." diff --git a/server/plugin/plugin.go b/server/plugin/plugin.go index 73a7f26..27360f6 100644 --- a/server/plugin/plugin.go +++ b/server/plugin/plugin.go @@ -16,6 +16,7 @@ import ( "github.com/pkg/errors" "github.com/darkLord19/mattermost-plugin-google-drive/server/plugin/config" + "github.com/darkLord19/mattermost-plugin-google-drive/server/plugin/google" "github.com/darkLord19/mattermost-plugin-google-drive/server/plugin/kvstore" "github.com/darkLord19/mattermost-plugin-google-drive/server/plugin/model" "github.com/darkLord19/mattermost-plugin-google-drive/server/plugin/utils" @@ -50,6 +51,8 @@ type Plugin struct { oauthBroker *OAuthBroker channelRefreshJob *cluster.Job + + GoogleClient *google.Client } func (p *Plugin) ensurePluginAPIClient() { @@ -168,6 +171,8 @@ func (p *Plugin) OnActivate() error { if err != nil { return errors.Wrap(err, "failed to create a scheduled recurring job to refresh watch channels") } + + p.GoogleClient = google.NewGoogleClient(p.getOAuthConfig(), p.getConfiguration(), p.KVStore, p.API) return nil } From 7bcd5f9224479662984529feab8da4f28858dfa5 Mon Sep 17 00:00:00 2001 From: Benjamin Cooke Date: Fri, 18 Oct 2024 15:49:27 -0400 Subject: [PATCH 02/17] create other service functions and finish wrapping --- server/plugin/api.go | 57 ++---- server/plugin/connect.go | 42 +---- server/plugin/create.go | 13 +- server/plugin/google/docs.go | 19 ++ server/plugin/google/drive.go | 104 ++--------- server/plugin/google/driveactivity.go | 19 ++ server/plugin/google/google.go | 255 ++++++++++++++++++++++++-- server/plugin/google/sheets.go | 19 ++ server/plugin/google/slides.go | 19 ++ server/plugin/kvstore/google_drive.go | 24 ++- server/plugin/kvstore/kvstore.go | 8 +- 11 files changed, 371 insertions(+), 208 deletions(-) create mode 100644 server/plugin/google/docs.go create mode 100644 server/plugin/google/driveactivity.go create mode 100644 server/plugin/google/sheets.go create mode 100644 server/plugin/google/slides.go diff --git a/server/plugin/api.go b/server/plugin/api.go index 09a289c..b21cf6d 100644 --- a/server/plugin/api.go +++ b/server/plugin/api.go @@ -22,7 +22,6 @@ import ( "google.golang.org/api/docs/v1" "google.golang.org/api/drive/v3" "google.golang.org/api/driveactivity/v2" - "google.golang.org/api/option" "google.golang.org/api/sheets/v4" "google.golang.org/api/slides/v1" @@ -384,47 +383,21 @@ func (p *Plugin) handleFileCreation(c *Context, w http.ResponseWriter, r *http.R return } - driveService, driveServiceError := p.GoogleClient.NewDriveService(c.Ctx, c.UserID) - if driveServiceError != nil { - p.API.LogError("Failed to create Google Drive client", "err", driveServiceError) - p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusInternalServerError}) - return - } - for i := 0; i < 11; i++ { - _, aboutErr := driveService.About(c.Ctx, "*") - if aboutErr != nil { - p.API.LogError("Failed to get Google Drive about", "err", aboutErr) - p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusInternalServerError}) - return - } - } - - if driveService != nil { - return - } - conf := p.getOAuthConfig() - authToken, err := p.getGoogleUserToken(c.UserID) - if err != nil { - p.API.LogError("Failed to get Google user token", "err", err) - p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusInternalServerError}) - return - } - var fileCreationErr error createdFileID := "" fileType := r.URL.Query().Get("type") switch fileType { case "doc": { - srv, dErr := docs.NewService(c.Ctx, option.WithTokenSource(conf.TokenSource(c.Ctx, authToken))) + srv, dErr := p.GoogleClient.NewDocsService(c.Ctx, c.UserID) if dErr != nil { p.API.LogError("Failed to create Google Docs client", "err", dErr) p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusInternalServerError}) return } - doc, dErr := srv.Documents.Create(&docs.Document{ + doc, dErr := srv.Create(&docs.Document{ Title: fileCreationParams.Name, - }).Do() + }) if dErr != nil { fileCreationErr = dErr break @@ -433,15 +406,15 @@ func (p *Plugin) handleFileCreation(c *Context, w http.ResponseWriter, r *http.R } case "slide": { - srv, dErr := slides.NewService(c.Ctx, option.WithTokenSource(conf.TokenSource(c.Ctx, authToken))) + srv, dErr := p.GoogleClient.NewSlidesService(c.Ctx, c.UserID) if dErr != nil { p.API.LogError("Failed to create Google Slides client", "err", dErr) p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusInternalServerError}) return } - slide, dErr := srv.Presentations.Create(&slides.Presentation{ + slide, dErr := srv.Create(&slides.Presentation{ Title: fileCreationParams.Name, - }).Do() + }) if dErr != nil { fileCreationErr = dErr break @@ -450,17 +423,17 @@ func (p *Plugin) handleFileCreation(c *Context, w http.ResponseWriter, r *http.R } case "sheet": { - srv, dErr := sheets.NewService(c.Ctx, option.WithTokenSource(conf.TokenSource(c.Ctx, authToken))) + srv, dErr := p.GoogleClient.NewSheetsService(c.Ctx, c.UserID) if dErr != nil { p.API.LogError("Failed to create Google Sheets client", "err", dErr) p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusInternalServerError}) return } - sheet, dErr := srv.Spreadsheets.Create(&sheets.Spreadsheet{ + sheet, dErr := srv.Create(&sheets.Spreadsheet{ Properties: &sheets.SpreadsheetProperties{ Title: fileCreationParams.Name, }, - }).Do() + }) if dErr != nil { fileCreationErr = dErr break @@ -513,14 +486,6 @@ func (p *Plugin) handleDriveWatchNotifications(c *Context, w http.ResponseWriter return } - conf := p.getOAuthConfig() - authToken, err := p.getGoogleUserToken(userID) - if err != nil { - p.API.LogError("Failed to get Google user token", "err", err, "userID", userID) - w.WriteHeader(http.StatusInternalServerError) - return - } - driveService, err := p.GoogleClient.NewDriveService(c.Ctx, userID) if err != nil { p.API.LogError("Failed to create Google Drive service", "err", err, "userID", userID) @@ -599,7 +564,7 @@ func (p *Plugin) handleDriveWatchNotifications(c *Context, w http.ResponseWriter return } - activitySrv, err := driveactivity.NewService(context.Background(), option.WithTokenSource(conf.TokenSource(context.Background(), authToken))) + activitySrv, err := p.GoogleClient.NewDriveActivityService(c.Ctx, userID) if err != nil { pageTokenErr = err p.API.LogError("Failed to fetch Google Drive changes", "err", err, "userID", userID) @@ -643,7 +608,7 @@ func (p *Plugin) handleDriveWatchNotifications(c *Context, w http.ResponseWriter var activities []*driveactivity.DriveActivity for { var activityRes *driveactivity.QueryDriveActivityResponse - activityRes, err = activitySrv.Activity.Query(driveActivityQuery).Do() + activityRes, err = activitySrv.Query(driveActivityQuery) if err != nil { p.API.LogError("Failed to fetch google drive activity", "err", err, "fileID", change.FileId, "userID", userID) continue diff --git a/server/plugin/connect.go b/server/plugin/connect.go index 47c85b4..96e33e2 100644 --- a/server/plugin/connect.go +++ b/server/plugin/connect.go @@ -1,52 +1,18 @@ package plugin import ( - "encoding/json" "fmt" "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/plugin" - "golang.org/x/oauth2" - - "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/utils" ) -func (p *Plugin) getGoogleUserToken(userID string) (*oauth2.Token, error) { - config := p.getConfiguration() - - encryptedToken, err := p.KVStore.GetGoogleUserToken(userID) - if err != nil { - return nil, err - } - - if len(encryptedToken) == 0 { - return nil, nil - } - - decryptedToken, err := utils.Decrypt([]byte(config.EncryptionKey), string(encryptedToken)) - if err != nil { - return nil, err - } - - var oauthToken oauth2.Token - err = json.Unmarshal([]byte(decryptedToken), &oauthToken) - - return &oauthToken, err -} - -func (p *Plugin) isUserConnected(userID string) (bool, error) { - encryptedToken, err := p.KVStore.GetGoogleUserToken(userID) +func (p *Plugin) handleConnect(c *plugin.Context, args *model.CommandArgs, parameters []string) string { + encryptedToken, err := p.KVStore.GetGoogleUserToken(args.UserId) if err != nil { - return false, err - } - if len(encryptedToken) == 0 { - return false, nil + return "Encountered an error connecting to Google Drive." } - return true, nil -} - -func (p *Plugin) handleConnect(c *plugin.Context, args *model.CommandArgs, parameters []string) string { - if connected, err := p.isUserConnected(args.UserId); connected && err == nil { + if len(encryptedToken) > 0 { return "You have already connected your Google account. If you want to reconnect then disconnect the account first using `/google-drive disconnect`." } siteURL := p.Client.Configuration.GetConfig().ServiceSettings.SiteURL diff --git a/server/plugin/create.go b/server/plugin/create.go index 8bed693..3ad6ac0 100644 --- a/server/plugin/create.go +++ b/server/plugin/create.go @@ -12,9 +12,7 @@ import ( "github.com/mattermost/mattermost/server/public/plugin" "golang.org/x/text/cases" "golang.org/x/text/language" - driveV2 "google.golang.org/api/drive/v2" "google.golang.org/api/drive/v3" - "google.golang.org/api/option" ) func (p *Plugin) sendFileCreatedMessage(ctx context.Context, channelID, fileID, userID, message string, shareInChannel bool) error { @@ -208,20 +206,13 @@ func (p *Plugin) handleCreate(c *plugin.Context, args *model.CommandArgs, parame }) ctx := context.Background() - conf := p.getOAuthConfig() - authToken, err := p.getGoogleUserToken(args.UserId) - if err != nil { - p.API.LogError("Failed to get user token", "err", err) - return "Failed to open file creation dialog" - } - - srvV2, err := driveV2.NewService(ctx, option.WithTokenSource(conf.TokenSource(ctx, authToken))) + serviceV2, err := p.GoogleClient.NewDriveV2Service(ctx, args.UserId) if err != nil { p.API.LogError("Failed to create drive client", "err", err) return "Failed to open file creation dialog. Please contact your system administrator." } - about, err := srvV2.About.Get().Fields("domainSharingPolicy").Do() + about, err := serviceV2.About(ctx, "domainSharingPolicy") if err != nil { p.API.LogError("Failed to get user information", "err", err) return "Failed to open file creation dialog. Please contact your system administrator." diff --git a/server/plugin/google/docs.go b/server/plugin/google/docs.go new file mode 100644 index 0000000..191b14a --- /dev/null +++ b/server/plugin/google/docs.go @@ -0,0 +1,19 @@ +package google + +import ( + "google.golang.org/api/docs/v1" +) + +type DocsService struct { + service *docs.Service + GoogleServiceBase +} + +func (ds *DocsService) Create(doc *docs.Document) (*docs.Document, error) { + d, err := ds.service.Documents.Create(doc).Do() + if err != nil { + ds.parseGoogleErrors(err) + return nil, err + } + return d, nil +} diff --git a/server/plugin/google/drive.go b/server/plugin/google/drive.go index 3966f3a..6921fd5 100644 --- a/server/plugin/google/drive.go +++ b/server/plugin/google/drive.go @@ -3,99 +3,21 @@ package google import ( "bytes" "context" - "encoding/json" - "errors" - "github.com/mattermost/mattermost/server/public/plugin" - - "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/kvstore" - "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/model" - - "golang.org/x/time/rate" + driveV2 "google.golang.org/api/drive/v2" "google.golang.org/api/drive/v3" + "google.golang.org/api/googleapi" ) type DriveService struct { service *drive.Service - limiter *rate.Limiter - papi plugin.API - userID string - kvstore kvstore.KVStore + GoogleServiceBase } -func (ds DriveService) parseGoogleErrors(err error) { - if googleErr, ok := err.(*googleapi.Error); ok { - reason := "" - if len(googleErr.Errors) > 0 { - for _, error := range googleErr.Errors { - if error.Reason != "" { - reason = error.Reason - break - } - } - } - if reason == "userRateLimitExceeded" { - err = ds.kvstore.StoreUserRateLimitExceeded(ds.userID) - if err != nil { - ds.papi.LogError("Failed to store user rate limit exceeded", "userID", ds.userID, "err", err) - return - } - } - if reason == "rateLimitExceeded" && len(googleErr.Details) > 0 { - for _, detail := range googleErr.Details { - byteData, _ := json.Marshal(detail) - var errDetail *model.ErrorDetail - jsonErr := json.Unmarshal(byteData, &errDetail) - if jsonErr != nil { - ds.papi.LogError("Failed to parse error details", "err", jsonErr) - continue - } - - if errDetail != nil { - // Even if the original "reason" is rateLimitExceeded, we need to check the QuotaLimit field in the metadata because it might only apply to this specific user. - if errDetail.Reason == "RATE_LIMIT_EXCEEDED" && errDetail.Metadata.QuotaLimit == "defaultPerMinutePerUser" { - err = ds.kvstore.StoreUserRateLimitExceeded(ds.userID) - if err != nil { - ds.papi.LogError("Failed to store user rate limit exceeded", "userID", ds.userID, "err", err) - return - } - } else if errDetail.Reason == "RATE_LIMIT_EXCEEDED" && errDetail.Metadata.QuotaLimit == "defaultPerMinutePerProject" { - err = ds.kvstore.StoreProjectRateLimitExceeded() - if err != nil { - ds.papi.LogError("Failed to store rate limit exceeded", "err", err) - return - } - } - } - } - } - } -} - -func (ds DriveService) checkRateLimits(ctx context.Context) error { - userIsRateLimited, err := ds.kvstore.GetUserRateLimitExceeded(ds.userID) - if err != nil { - return err - } - if userIsRateLimited { - return errors.New("user rate limit exceeded") - } - - projectIsRateLimited, err := ds.kvstore.GetProjectRateLimitExceeded() - if err != nil { - return err - } - if projectIsRateLimited { - return errors.New("project rate limit exceeded") - } - - err = ds.limiter.WaitN(ctx, 1) - if err != nil { - return err - } - - return nil +type DriveServiceV2 struct { + serviceV2 *driveV2.Service + GoogleServiceBase } func (ds DriveService) About(ctx context.Context, fields googleapi.Field) (*drive.About, error) { @@ -229,3 +151,17 @@ func (ds DriveService) CreatePermission(ctx context.Context, fileID string, perm } return googlePermission, nil } + +func (ds DriveServiceV2) About(ctx context.Context, fields googleapi.Field) (*driveV2.About, error) { + err := ds.checkRateLimits(ctx) + if err != nil { + return nil, err + } + + da, err := ds.serviceV2.About.Get().Fields(fields).Do() + if err != nil { + ds.parseGoogleErrors(err) + return nil, err + } + return da, nil +} diff --git a/server/plugin/google/driveactivity.go b/server/plugin/google/driveactivity.go new file mode 100644 index 0000000..40e2f40 --- /dev/null +++ b/server/plugin/google/driveactivity.go @@ -0,0 +1,19 @@ +package google + +import ( + "google.golang.org/api/driveactivity/v2" +) + +type DriveActivityService struct { + service *driveactivity.Service + GoogleServiceBase +} + +func (ds *DriveActivityService) Query(request *driveactivity.QueryDriveActivityRequest) (*driveactivity.QueryDriveActivityResponse, error) { + p, err := ds.service.Activity.Query(request).Do() + if err != nil { + ds.parseGoogleErrors(err) + return nil, err + } + return p, nil +} diff --git a/server/plugin/google/google.go b/server/plugin/google/google.go index f805f4e..ecf1fff 100644 --- a/server/plugin/google/google.go +++ b/server/plugin/google/google.go @@ -3,34 +3,58 @@ package google import ( "context" "encoding/json" + "errors" "time" "github.com/mattermost/mattermost/server/public/plugin" "golang.org/x/oauth2" "golang.org/x/time/rate" + "google.golang.org/api/docs/v1" + driveV2 "google.golang.org/api/drive/v2" "google.golang.org/api/drive/v3" + "google.golang.org/api/driveactivity/v2" + "google.golang.org/api/googleapi" "google.golang.org/api/option" + "google.golang.org/api/sheets/v4" + "google.golang.org/api/slides/v1" "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/config" "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/kvstore" + "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/model" "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/utils" ) type Client struct { - oauthConfig *oauth2.Config - config *config.Configuration - kvstore kvstore.KVStore - papi plugin.API + oauthConfig *oauth2.Config + config *config.Configuration + kvstore kvstore.KVStore + papi plugin.API + driveLimiter *rate.Limiter +} + +type GoogleServiceBase struct { + serviceType string limiter *rate.Limiter + papi plugin.API + userID string + kvstore kvstore.KVStore } +const ( + driveServiceType = "drive" + docsServiceType = "docs" + slidesServiceType = "slides" + sheetsServiceType = "sheets" + driveActivityServiceType = "driveactivity" +) + func NewGoogleClient(oauthConfig *oauth2.Config, config *config.Configuration, kvstore kvstore.KVStore, papi plugin.API) *Client { return &Client{ - oauthConfig: oauthConfig, - config: config, - kvstore: kvstore, - papi: papi, - limiter: rate.NewLimiter(rate.Every(time.Second), 10), + oauthConfig: oauthConfig, + config: config, + kvstore: kvstore, + papi: papi, + driveLimiter: rate.NewLimiter(rate.Every(time.Second), 10), } } @@ -39,8 +63,8 @@ func (g *Client) NewDriveService(ctx context.Context, userID string) (*DriveServ if err != nil { return nil, err } - if !g.limiter.Allow() { - err = g.limiter.WaitN(ctx, 1) + if !g.driveLimiter.Allow() { + err = g.driveLimiter.WaitN(ctx, 1) if err != nil { return nil, err } @@ -53,10 +77,134 @@ func (g *Client) NewDriveService(ctx context.Context, userID string) (*DriveServ return &DriveService{ service: srv, - papi: g.papi, - limiter: g.limiter, - userID: userID, - kvstore: g.kvstore, + GoogleServiceBase: GoogleServiceBase{ + serviceType: driveServiceType, + papi: g.papi, + limiter: g.driveLimiter, + userID: userID, + kvstore: g.kvstore, + }, + }, nil +} + +func (g *Client) NewDriveV2Service(ctx context.Context, userID string) (*DriveServiceV2, error) { + authToken, err := g.getGoogleUserToken(userID) + if err != nil { + return nil, err + } + if !g.driveLimiter.Allow() { + err = g.driveLimiter.WaitN(ctx, 1) + if err != nil { + return nil, err + } + } + + srv, err := driveV2.NewService(ctx, option.WithTokenSource(g.oauthConfig.TokenSource(ctx, authToken))) + if err != nil { + return nil, err + } + + return &DriveServiceV2{ + serviceV2: srv, + GoogleServiceBase: GoogleServiceBase{ + serviceType: driveServiceType, + papi: g.papi, + limiter: g.driveLimiter, + userID: userID, + kvstore: g.kvstore, + }, + }, nil +} + +func (g *Client) NewDocsService(ctx context.Context, userID string) (*DocsService, error) { + authToken, err := g.getGoogleUserToken(userID) + if err != nil { + return nil, err + } + + srv, err := docs.NewService(ctx, option.WithTokenSource(g.oauthConfig.TokenSource(ctx, authToken))) + if err != nil { + return nil, err + } + + return &DocsService{ + service: srv, + GoogleServiceBase: GoogleServiceBase{ + serviceType: docsServiceType, + papi: g.papi, + limiter: nil, + userID: userID, + kvstore: g.kvstore, + }, + }, nil +} + +func (g *Client) NewSlidesService(ctx context.Context, userID string) (*SlidesService, error) { + authToken, err := g.getGoogleUserToken(userID) + if err != nil { + return nil, err + } + + srv, err := slides.NewService(ctx, option.WithTokenSource(g.oauthConfig.TokenSource(ctx, authToken))) + if err != nil { + return nil, err + } + + return &SlidesService{ + service: srv, + GoogleServiceBase: GoogleServiceBase{ + serviceType: slidesServiceType, + papi: g.papi, + limiter: nil, + userID: userID, + kvstore: g.kvstore, + }, + }, nil +} + +func (g *Client) NewSheetsService(ctx context.Context, userID string) (*SheetsService, error) { + authToken, err := g.getGoogleUserToken(userID) + if err != nil { + return nil, err + } + + srv, err := sheets.NewService(ctx, option.WithTokenSource(g.oauthConfig.TokenSource(ctx, authToken))) + if err != nil { + return nil, err + } + + return &SheetsService{ + service: srv, + GoogleServiceBase: GoogleServiceBase{ + serviceType: sheetsServiceType, + papi: g.papi, + limiter: nil, + userID: userID, + kvstore: g.kvstore, + }, + }, nil +} + +func (g *Client) NewDriveActivityService(ctx context.Context, userID string) (*DriveActivityService, error) { + authToken, err := g.getGoogleUserToken(userID) + if err != nil { + return nil, err + } + + srv, err := driveactivity.NewService(ctx, option.WithTokenSource(g.oauthConfig.TokenSource(ctx, authToken))) + if err != nil { + return nil, err + } + + return &DriveActivityService{ + service: srv, + GoogleServiceBase: GoogleServiceBase{ + serviceType: driveActivityServiceType, + papi: g.papi, + limiter: nil, + userID: userID, + kvstore: g.kvstore, + }, }, nil } @@ -80,3 +228,80 @@ func (g *Client) getGoogleUserToken(userID string) (*oauth2.Token, error) { return &oauthToken, err } + +func (ds GoogleServiceBase) parseGoogleErrors(err error) { + if googleErr, ok := err.(*googleapi.Error); ok { + reason := "" + if len(googleErr.Errors) > 0 { + for _, error := range googleErr.Errors { + if error.Reason != "" { + reason = error.Reason + break + } + } + } + if reason == "userRateLimitExceeded" { + err = ds.kvstore.StoreUserRateLimitExceeded(ds.serviceType, ds.userID) + if err != nil { + ds.papi.LogError("Failed to store user rate limit exceeded", "userID", ds.userID, "err", err) + return + } + } + if reason == "rateLimitExceeded" && len(googleErr.Details) > 0 { + for _, detail := range googleErr.Details { + byteData, _ := json.Marshal(detail) + var errDetail *model.ErrorDetail + jsonErr := json.Unmarshal(byteData, &errDetail) + if jsonErr != nil { + ds.papi.LogError("Failed to parse error details", "err", jsonErr) + continue + } + + if errDetail != nil { + // Even if the original "reason" is rateLimitExceeded, we need to check the QuotaLimit field in the metadata because it might only apply to this specific user. + if errDetail.Reason == "RATE_LIMIT_EXCEEDED" && errDetail.Metadata.QuotaLimit == "defaultPerMinutePerUser" { + err = ds.kvstore.StoreUserRateLimitExceeded(ds.serviceType, ds.userID) + if err != nil { + ds.papi.LogError("Failed to store user rate limit exceeded", "userID", ds.userID, "err", err) + return + } + } else if errDetail.Reason == "RATE_LIMIT_EXCEEDED" && errDetail.Metadata.QuotaLimit == "defaultPerMinutePerProject" { + err = ds.kvstore.StoreProjectRateLimitExceeded(ds.serviceType) + if err != nil { + ds.papi.LogError("Failed to store rate limit exceeded", "err", err) + return + } + } + } + } + } + } +} + +func (ds GoogleServiceBase) checkRateLimits(ctx context.Context) error { + if ds.limiter == nil { + return nil + } + userIsRateLimited, err := ds.kvstore.GetUserRateLimitExceeded(ds.serviceType, ds.userID) + if err != nil { + return err + } + if userIsRateLimited { + return errors.New("user rate limit exceeded") + } + + projectIsRateLimited, err := ds.kvstore.GetProjectRateLimitExceeded(ds.serviceType) + if err != nil { + return err + } + if projectIsRateLimited { + return errors.New("project rate limit exceeded") + } + + err = ds.limiter.WaitN(ctx, 1) + if err != nil { + return err + } + + return nil +} diff --git a/server/plugin/google/sheets.go b/server/plugin/google/sheets.go new file mode 100644 index 0000000..f847dca --- /dev/null +++ b/server/plugin/google/sheets.go @@ -0,0 +1,19 @@ +package google + +import ( + "google.golang.org/api/sheets/v4" +) + +type SheetsService struct { + service *sheets.Service + GoogleServiceBase +} + +func (ds *SheetsService) Create(spreadsheet *sheets.Spreadsheet) (*sheets.Spreadsheet, error) { + p, err := ds.service.Spreadsheets.Create(spreadsheet).Do() + if err != nil { + ds.parseGoogleErrors(err) + return nil, err + } + return p, nil +} diff --git a/server/plugin/google/slides.go b/server/plugin/google/slides.go new file mode 100644 index 0000000..1fd5c79 --- /dev/null +++ b/server/plugin/google/slides.go @@ -0,0 +1,19 @@ +package google + +import ( + "google.golang.org/api/slides/v1" +) + +type SlidesService struct { + service *slides.Service + GoogleServiceBase +} + +func (ds *SlidesService) Create(presentation *slides.Presentation) (*slides.Presentation, error) { + p, err := ds.service.Presentations.Create(presentation).Do() + if err != nil { + ds.parseGoogleErrors(err) + return nil, err + } + return p, nil +} diff --git a/server/plugin/kvstore/google_drive.go b/server/plugin/kvstore/google_drive.go index 2f5a55d..e90bbea 100644 --- a/server/plugin/kvstore/google_drive.go +++ b/server/plugin/kvstore/google_drive.go @@ -33,8 +33,12 @@ func getLastActivityKey(userID, fileID string) string { return fmt.Sprintf("last_activity-%s-%s", userID, fileID) } -func getRateLimitKey(userID string) string { - return fmt.Sprintf("rate_limit_exceeded-%s", userID) +func getUserRateLimitKey(serviceType string, userID string) string { + return fmt.Sprintf("user-rate_limited-%s-%s", serviceType, userID) +} + +func getProjectRateLimitKey(serviceType string) string { + return fmt.Sprintf("user-rate_limited-%s", serviceType) } func (kv Impl) GetWatchChannelData(userID string) (*model.WatchChannelData, error) { @@ -151,8 +155,8 @@ func (kv Impl) DeleteGoogleUserToken(userID string) error { return nil } -func (kv Impl) StoreUserRateLimitExceeded(userID string) error { - saved, err := kv.client.KV.Set(getRateLimitKey(userID), []byte("true"), pluginapi.SetExpiry(time.Second*10)) +func (kv Impl) StoreUserRateLimitExceeded(serviceType string, userID string) error { + saved, err := kv.client.KV.Set(getUserRateLimitKey(serviceType, userID), []byte("true"), pluginapi.SetExpiry(time.Second*10)) if !saved && err != nil { return errors.Wrap(err, "database error occurred when trying to save user rate limit exceeded") } else if !saved && err == nil { @@ -161,18 +165,18 @@ func (kv Impl) StoreUserRateLimitExceeded(userID string) error { return nil } -func (kv Impl) GetUserRateLimitExceeded(userID string) (bool, error) { +func (kv Impl) GetUserRateLimitExceeded(serviceType string, userID string) (bool, error) { var rateLimitExceeded bool - err := kv.client.KV.Get(getRateLimitKey(userID), &rateLimitExceeded) + err := kv.client.KV.Get(getUserRateLimitKey(serviceType, userID), &rateLimitExceeded) if err != nil { return false, errors.Wrap(err, "failed to get user rate limit exceeded") } return rateLimitExceeded, nil } -func (kv Impl) StoreProjectRateLimitExceeded() error { - saved, err := kv.client.KV.Set("project_rate_limit_exceeded", []byte("true"), pluginapi.SetExpiry(time.Second*10)) +func (kv Impl) StoreProjectRateLimitExceeded(serviceType string) error { + saved, err := kv.client.KV.Set(getProjectRateLimitKey(serviceType), []byte("true"), pluginapi.SetExpiry(time.Second*10)) if !saved && err != nil { return errors.Wrap(err, "database error occurred when trying to save project rate limit exceeded") } else if !saved && err == nil { @@ -181,10 +185,10 @@ func (kv Impl) StoreProjectRateLimitExceeded() error { return nil } -func (kv Impl) GetProjectRateLimitExceeded() (bool, error) { +func (kv Impl) GetProjectRateLimitExceeded(serviceType string) (bool, error) { var rateLimitExceeded bool - err := kv.client.KV.Get("project_rate_limit_exceeded", &rateLimitExceeded) + err := kv.client.KV.Get(getProjectRateLimitKey(serviceType), &rateLimitExceeded) if err != nil { return false, errors.Wrap(err, "failed to get project rate limit exceeded") } diff --git a/server/plugin/kvstore/kvstore.go b/server/plugin/kvstore/kvstore.go index eec41a4..5742c36 100644 --- a/server/plugin/kvstore/kvstore.go +++ b/server/plugin/kvstore/kvstore.go @@ -19,9 +19,9 @@ type KVStore interface { GetGoogleUserToken(userID string) ([]byte, error) DeleteGoogleUserToken(userID string) error - StoreUserRateLimitExceeded(userID string) error - GetUserRateLimitExceeded(userID string) (bool, error) + StoreUserRateLimitExceeded(serviceType string, userID string) error + GetUserRateLimitExceeded(serviceType string, userID string) (bool, error) - StoreProjectRateLimitExceeded() error - GetProjectRateLimitExceeded() (bool, error) + StoreProjectRateLimitExceeded(serviceType string) error + GetProjectRateLimitExceeded(serviceType string) (bool, error) } From 675fd0acdadc4e32e7ee7626a7cc1d92a4fd1002 Mon Sep 17 00:00:00 2001 From: Benjamin Cooke Date: Fri, 18 Oct 2024 16:01:57 -0400 Subject: [PATCH 03/17] move commands to one file to clear up clutter in the root directory --- server/plugin/about.go | 20 --------- server/plugin/command.go | 81 +++++++++++++++++++++++++++++++++++++ server/plugin/connect.go | 24 ----------- server/plugin/disconnect.go | 25 ------------ server/plugin/help.go | 20 --------- server/plugin/setup.go | 40 ------------------ 6 files changed, 81 insertions(+), 129 deletions(-) delete mode 100644 server/plugin/about.go delete mode 100644 server/plugin/connect.go delete mode 100644 server/plugin/disconnect.go delete mode 100644 server/plugin/help.go delete mode 100644 server/plugin/setup.go diff --git a/server/plugin/about.go b/server/plugin/about.go deleted file mode 100644 index 7d7137e..0000000 --- a/server/plugin/about.go +++ /dev/null @@ -1,20 +0,0 @@ -package plugin - -import ( - "github.com/mattermost/mattermost/server/public/model" - "github.com/mattermost/mattermost/server/public/plugin" - "github.com/mattermost/mattermost/server/public/pluginapi/experimental/command" - "github.com/pkg/errors" -) - -func (p *Plugin) handleAbout(c *plugin.Context, args *model.CommandArgs, parameters []string) string { - text, err := command.BuildInfo(model.Manifest{ - Id: Manifest.Id, - Version: Manifest.Version, - Name: Manifest.Name, - }) - if err != nil { - text = errors.Wrap(err, "failed to get build info").Error() - } - return text -} diff --git a/server/plugin/command.go b/server/plugin/command.go index fa94d2c..cd1c506 100644 --- a/server/plugin/command.go +++ b/server/plugin/command.go @@ -1,6 +1,8 @@ package plugin import ( + "fmt" + "strings" "unicode" "github.com/mattermost/mattermost/server/public/model" @@ -157,3 +159,82 @@ func (p *Plugin) ExecuteCommand(c *plugin.Context, args *model.CommandArgs) (*mo return &model.CommandResponse{}, nil } + +func (p *Plugin) handleAbout(c *plugin.Context, args *model.CommandArgs, parameters []string) string { + text, err := command.BuildInfo(model.Manifest{ + Id: Manifest.Id, + Version: Manifest.Version, + Name: Manifest.Name, + }) + if err != nil { + text = errors.Wrap(err, "failed to get build info").Error() + } + return text +} + +func (p *Plugin) handleConnect(c *plugin.Context, args *model.CommandArgs, parameters []string) string { + encryptedToken, err := p.KVStore.GetGoogleUserToken(args.UserId) + if err != nil { + return "Encountered an error connecting to Google Drive." + } + if len(encryptedToken) > 0 { + return "You have already connected your Google account. If you want to reconnect then disconnect the account first using `/google-drive disconnect`." + } + siteURL := p.Client.Configuration.GetConfig().ServiceSettings.SiteURL + if siteURL == nil { + return "Encountered an error connecting to Google Drive." + } + + return fmt.Sprintf("[Click here to link your Google account.](%s/plugins/%s/oauth/connect)", *siteURL, Manifest.Id) +} + +func (p *Plugin) handleDisconnect(c *plugin.Context, args *model.CommandArgs, _ []string) string { + encryptedToken, err := p.KVStore.GetGoogleUserToken(args.UserId) + if err != nil { + p.Client.Log.Error("Failed to disconnect google account", "error", err) + return "Encountered an error disconnecting Google account." + } + + if len(encryptedToken) == 0 { + return "There is no Google account connected to your Mattermost account." + } + + err = p.KVStore.DeleteGoogleUserToken(args.UserId) + if err != nil { + p.Client.Log.Error("Failed to disconnect Google account", "error", err) + return "Encountered an error disconnecting Google account." + } + return "Disconnected your Google account." +} + +func (p *Plugin) handleSetup(c *plugin.Context, args *model.CommandArgs, parameters []string) string { + userID := args.UserId + user, err := p.Client.User.Get(userID) + if err != nil { + p.Client.Log.Warn("Failed to check if user is System Admin", "error", err.Error()) + return "Error checking user's permissions" + } + if !strings.Contains(user.Roles, "system_admin") { + return "Only System Admins are allowed to set up the plugin." + } + + err = p.FlowManager.StartSetupWizard(userID) + + if err != nil { + return err.Error() + } + + return "" +} + +const commandHelp = `* |/google-drive connect| - Connect to your Google account +* |/google-drive disconnect| - Disconnect your Google account +* |/google-drive create [doc/slide/sheet]| - Create and share Google documents, spreadsheets and presentations right from Mattermost. +* |/google-drive notifications start| - Enable notification for Google files sharing and comments on files. +* |/google-drive notifications stop| - Disable notification for Google files sharing and comments on files. +* |/google-drive help| - Get help for available slash commands. +* |/google-drive about| - Display build information about the plugin.` + +func (p *Plugin) handleHelp(_ *plugin.Context, _ *model.CommandArgs, _ []string) string { + return "###### Mattermost Google Drive Plugin - Slash Command Help\n" + strings.ReplaceAll(commandHelp, "|", "`") +} diff --git a/server/plugin/connect.go b/server/plugin/connect.go deleted file mode 100644 index 96e33e2..0000000 --- a/server/plugin/connect.go +++ /dev/null @@ -1,24 +0,0 @@ -package plugin - -import ( - "fmt" - - "github.com/mattermost/mattermost/server/public/model" - "github.com/mattermost/mattermost/server/public/plugin" -) - -func (p *Plugin) handleConnect(c *plugin.Context, args *model.CommandArgs, parameters []string) string { - encryptedToken, err := p.KVStore.GetGoogleUserToken(args.UserId) - if err != nil { - return "Encountered an error connecting to Google Drive." - } - if len(encryptedToken) > 0 { - return "You have already connected your Google account. If you want to reconnect then disconnect the account first using `/google-drive disconnect`." - } - siteURL := p.Client.Configuration.GetConfig().ServiceSettings.SiteURL - if siteURL == nil { - return "Encountered an error connecting to Google Drive." - } - - return fmt.Sprintf("[Click here to link your Google account.](%s/plugins/%s/oauth/connect)", *siteURL, Manifest.Id) -} diff --git a/server/plugin/disconnect.go b/server/plugin/disconnect.go deleted file mode 100644 index 4e9c29c..0000000 --- a/server/plugin/disconnect.go +++ /dev/null @@ -1,25 +0,0 @@ -package plugin - -import ( - "github.com/mattermost/mattermost/server/public/model" - "github.com/mattermost/mattermost/server/public/plugin" -) - -func (p *Plugin) handleDisconnect(c *plugin.Context, args *model.CommandArgs, _ []string) string { - encryptedToken, err := p.KVStore.GetGoogleUserToken(args.UserId) - if err != nil { - p.Client.Log.Error("Failed to disconnect google account", "error", err) - return "Encountered an error disconnecting Google account." - } - - if len(encryptedToken) == 0 { - return "There is no Google account connected to your Mattermost account." - } - - err = p.KVStore.DeleteGoogleUserToken(args.UserId) - if err != nil { - p.Client.Log.Error("Failed to disconnect Google account", "error", err) - return "Encountered an error disconnecting Google account." - } - return "Disconnected your Google account." -} diff --git a/server/plugin/help.go b/server/plugin/help.go deleted file mode 100644 index 35af665..0000000 --- a/server/plugin/help.go +++ /dev/null @@ -1,20 +0,0 @@ -package plugin - -import ( - "strings" - - "github.com/mattermost/mattermost/server/public/model" - "github.com/mattermost/mattermost/server/public/plugin" -) - -const commandHelp = `* |/google-drive connect| - Connect to your Google account -* |/google-drive disconnect| - Disconnect your Google account -* |/google-drive create [doc/slide/sheet]| - Create and share Google documents, spreadsheets and presentations right from Mattermost. -* |/google-drive notifications start| - Enable notification for Google files sharing and comments on files. -* |/google-drive notifications stop| - Disable notification for Google files sharing and comments on files. -* |/google-drive help| - Get help for available slash commands. -* |/google-drive about| - Display build information about the plugin.` - -func (p *Plugin) handleHelp(_ *plugin.Context, _ *model.CommandArgs, _ []string) string { - return "###### Mattermost Google Drive Plugin - Slash Command Help\n" + strings.ReplaceAll(commandHelp, "|", "`") -} diff --git a/server/plugin/setup.go b/server/plugin/setup.go deleted file mode 100644 index 4815f2a..0000000 --- a/server/plugin/setup.go +++ /dev/null @@ -1,40 +0,0 @@ -package plugin - -import ( - "strings" - - "github.com/mattermost/mattermost/server/public/model" - "github.com/mattermost/mattermost/server/public/plugin" -) - -func (p *Plugin) isAuthorizedSysAdmin(userID string) (bool, error) { - user, err := p.Client.User.Get(userID) - if err != nil { - return false, err - } - if !strings.Contains(user.Roles, "system_admin") { - return false, nil - } - return true, nil -} - -func (p *Plugin) handleSetup(c *plugin.Context, args *model.CommandArgs, parameters []string) string { - userID := args.UserId - isSysAdmin, err := p.isAuthorizedSysAdmin(userID) - if err != nil { - p.Client.Log.Warn("Failed to check if user is System Admin", "error", err.Error()) - return "Error checking user's permissions" - } - - if !isSysAdmin { - return "Only System Admins are allowed to set up the plugin." - } - - err = p.FlowManager.StartSetupWizard(userID) - - if err != nil { - return err.Error() - } - - return "" -} From e431b78c9e43e434577bf65758df497c7be9785f Mon Sep 17 00:00:00 2001 From: Benjamin Cooke Date: Fri, 18 Oct 2024 16:09:37 -0400 Subject: [PATCH 04/17] lint --- server/plugin/google/docs.go | 2 +- server/plugin/google/drive.go | 4 ++-- server/plugin/google/driveactivity.go | 2 +- server/plugin/google/google.go | 18 +++++++++--------- server/plugin/google/sheets.go | 2 +- server/plugin/google/slides.go | 2 +- 6 files changed, 15 insertions(+), 15 deletions(-) diff --git a/server/plugin/google/docs.go b/server/plugin/google/docs.go index 191b14a..3dc2dc6 100644 --- a/server/plugin/google/docs.go +++ b/server/plugin/google/docs.go @@ -6,7 +6,7 @@ import ( type DocsService struct { service *docs.Service - GoogleServiceBase + googleServiceBase } func (ds *DocsService) Create(doc *docs.Document) (*docs.Document, error) { diff --git a/server/plugin/google/drive.go b/server/plugin/google/drive.go index 6921fd5..5f3f37a 100644 --- a/server/plugin/google/drive.go +++ b/server/plugin/google/drive.go @@ -12,12 +12,12 @@ import ( type DriveService struct { service *drive.Service - GoogleServiceBase + googleServiceBase } type DriveServiceV2 struct { serviceV2 *driveV2.Service - GoogleServiceBase + googleServiceBase } func (ds DriveService) About(ctx context.Context, fields googleapi.Field) (*drive.About, error) { diff --git a/server/plugin/google/driveactivity.go b/server/plugin/google/driveactivity.go index 40e2f40..320bbca 100644 --- a/server/plugin/google/driveactivity.go +++ b/server/plugin/google/driveactivity.go @@ -6,7 +6,7 @@ import ( type DriveActivityService struct { service *driveactivity.Service - GoogleServiceBase + googleServiceBase } func (ds *DriveActivityService) Query(request *driveactivity.QueryDriveActivityRequest) (*driveactivity.QueryDriveActivityResponse, error) { diff --git a/server/plugin/google/google.go b/server/plugin/google/google.go index ecf1fff..9174423 100644 --- a/server/plugin/google/google.go +++ b/server/plugin/google/google.go @@ -32,7 +32,7 @@ type Client struct { driveLimiter *rate.Limiter } -type GoogleServiceBase struct { +type googleServiceBase struct { serviceType string limiter *rate.Limiter papi plugin.API @@ -77,7 +77,7 @@ func (g *Client) NewDriveService(ctx context.Context, userID string) (*DriveServ return &DriveService{ service: srv, - GoogleServiceBase: GoogleServiceBase{ + googleServiceBase: googleServiceBase{ serviceType: driveServiceType, papi: g.papi, limiter: g.driveLimiter, @@ -106,7 +106,7 @@ func (g *Client) NewDriveV2Service(ctx context.Context, userID string) (*DriveSe return &DriveServiceV2{ serviceV2: srv, - GoogleServiceBase: GoogleServiceBase{ + googleServiceBase: googleServiceBase{ serviceType: driveServiceType, papi: g.papi, limiter: g.driveLimiter, @@ -129,7 +129,7 @@ func (g *Client) NewDocsService(ctx context.Context, userID string) (*DocsServic return &DocsService{ service: srv, - GoogleServiceBase: GoogleServiceBase{ + googleServiceBase: googleServiceBase{ serviceType: docsServiceType, papi: g.papi, limiter: nil, @@ -152,7 +152,7 @@ func (g *Client) NewSlidesService(ctx context.Context, userID string) (*SlidesSe return &SlidesService{ service: srv, - GoogleServiceBase: GoogleServiceBase{ + googleServiceBase: googleServiceBase{ serviceType: slidesServiceType, papi: g.papi, limiter: nil, @@ -175,7 +175,7 @@ func (g *Client) NewSheetsService(ctx context.Context, userID string) (*SheetsSe return &SheetsService{ service: srv, - GoogleServiceBase: GoogleServiceBase{ + googleServiceBase: googleServiceBase{ serviceType: sheetsServiceType, papi: g.papi, limiter: nil, @@ -198,7 +198,7 @@ func (g *Client) NewDriveActivityService(ctx context.Context, userID string) (*D return &DriveActivityService{ service: srv, - GoogleServiceBase: GoogleServiceBase{ + googleServiceBase: googleServiceBase{ serviceType: driveActivityServiceType, papi: g.papi, limiter: nil, @@ -229,7 +229,7 @@ func (g *Client) getGoogleUserToken(userID string) (*oauth2.Token, error) { return &oauthToken, err } -func (ds GoogleServiceBase) parseGoogleErrors(err error) { +func (ds googleServiceBase) parseGoogleErrors(err error) { if googleErr, ok := err.(*googleapi.Error); ok { reason := "" if len(googleErr.Errors) > 0 { @@ -278,7 +278,7 @@ func (ds GoogleServiceBase) parseGoogleErrors(err error) { } } -func (ds GoogleServiceBase) checkRateLimits(ctx context.Context) error { +func (ds googleServiceBase) checkRateLimits(ctx context.Context) error { if ds.limiter == nil { return nil } diff --git a/server/plugin/google/sheets.go b/server/plugin/google/sheets.go index f847dca..f92aded 100644 --- a/server/plugin/google/sheets.go +++ b/server/plugin/google/sheets.go @@ -6,7 +6,7 @@ import ( type SheetsService struct { service *sheets.Service - GoogleServiceBase + googleServiceBase } func (ds *SheetsService) Create(spreadsheet *sheets.Spreadsheet) (*sheets.Spreadsheet, error) { diff --git a/server/plugin/google/slides.go b/server/plugin/google/slides.go index 1fd5c79..fddc298 100644 --- a/server/plugin/google/slides.go +++ b/server/plugin/google/slides.go @@ -6,7 +6,7 @@ import ( type SlidesService struct { service *slides.Service - GoogleServiceBase + googleServiceBase } func (ds *SlidesService) Create(presentation *slides.Presentation) (*slides.Presentation, error) { From 02a9d838cd6e0653ca7b67d9c0525654386a3143 Mon Sep 17 00:00:00 2001 From: Benjamin Cooke Date: Mon, 21 Oct 2024 23:12:45 -0400 Subject: [PATCH 05/17] add configurations for rate limit and fix some bugs with notifications --- plugin.json | 18 ++++++++++++++++++ server/plugin/api.go | 27 ++++++++++++++++----------- server/plugin/config/configuration.go | 10 ++++++++++ server/plugin/google/docs.go | 6 ++++-- server/plugin/google/drive.go | 22 +++++++++++----------- server/plugin/google/driveactivity.go | 6 ++++-- server/plugin/google/google.go | 26 +++++++++++++++++--------- server/plugin/google/sheets.go | 6 ++++-- server/plugin/google/slides.go | 6 ++++-- server/plugin/plugin.go | 3 +++ 10 files changed, 91 insertions(+), 39 deletions(-) diff --git a/plugin.json b/plugin.json index 5df830f..a3c766d 100644 --- a/plugin.json +++ b/plugin.json @@ -48,6 +48,24 @@ "placeholder": "", "default": null, "hosting": "" + }, + { + "key": "QueriesPerMinute", + "display_name": "Maximum queries per minute:", + "type": "number", + "help_text": "The number of requests per minute allowed by your Google Drive API, you can find this number under the Quotas & System limits of your Google Drive API. If you are running a high availability setup you will need to divide your quota by the number of nodes in your Mattermost cluster. So if you have 3 Mattermost nodes running and your quoata is 12000 requests per minute, you will need to set this value to 4000.", + "placeholder": "", + "default": "12000", + "hosting": "" + }, + { + "key": "BurstSize", + "display_name": "Maximum burst size:", + "type": "number", + "help_text": "The maximum number of requests allowed beyond the **per second** query limit. This is useful for handling short bursts of requests.", + "placeholder": "", + "default": "300", + "hosting": "" } ] } diff --git a/server/plugin/api.go b/server/plugin/api.go index b21cf6d..f2f0daf 100644 --- a/server/plugin/api.go +++ b/server/plugin/api.go @@ -395,7 +395,7 @@ func (p *Plugin) handleFileCreation(c *Context, w http.ResponseWriter, r *http.R p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusInternalServerError}) return } - doc, dErr := srv.Create(&docs.Document{ + doc, dErr := srv.Create(c.Ctx, &docs.Document{ Title: fileCreationParams.Name, }) if dErr != nil { @@ -412,7 +412,7 @@ func (p *Plugin) handleFileCreation(c *Context, w http.ResponseWriter, r *http.R p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusInternalServerError}) return } - slide, dErr := srv.Create(&slides.Presentation{ + slide, dErr := srv.Create(c.Ctx, &slides.Presentation{ Title: fileCreationParams.Name, }) if dErr != nil { @@ -429,7 +429,7 @@ func (p *Plugin) handleFileCreation(c *Context, w http.ResponseWriter, r *http.R p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusInternalServerError}) return } - sheet, dErr := srv.Create(&sheets.Spreadsheet{ + sheet, dErr := srv.Create(c.Ctx, &sheets.Spreadsheet{ Properties: &sheets.SpreadsheetProperties{ Title: fileCreationParams.Name, }, @@ -582,7 +582,11 @@ func (p *Plugin) handleDriveWatchNotifications(c *Context, w http.ResponseWriter viewedByMeTime, _ := time.Parse(time.RFC3339, change.File.ViewedByMeTime) // Check if the user has already opened the file after the last change. - if lastChangeTime.Sub(modifiedTime) > lastChangeTime.Sub(viewedByMeTime) { + if lastChangeTime.Sub(modifiedTime) >= lastChangeTime.Sub(viewedByMeTime) { + err = p.KVStore.StoreLastActivityForFile(userID, change.FileId, change.File.ViewedByMeTime) + if err != nil { + p.API.LogError("Failed to store last activity for file", "err", err, "fileID", change.FileId, "userID", userID) + } continue } @@ -596,6 +600,10 @@ func (p *Plugin) handleDriveWatchNotifications(c *Context, w http.ResponseWriter continue } + if change.File.ViewedByMeTime > lastActivityTime { + lastActivityTime = change.File.ViewedByMeTime + } + // If we have a last activity timestamp for this file we can use it to filter the activities. if lastActivityTime != "" { driveActivityQuery.Filter = "time > \"" + lastActivityTime + "\"" @@ -608,7 +616,7 @@ func (p *Plugin) handleDriveWatchNotifications(c *Context, w http.ResponseWriter var activities []*driveactivity.DriveActivity for { var activityRes *driveactivity.QueryDriveActivityResponse - activityRes, err = activitySrv.Query(driveActivityQuery) + activityRes, err = activitySrv.Query(c.Ctx, driveActivityQuery) if err != nil { p.API.LogError("Failed to fetch google drive activity", "err", err, "fileID", change.FileId, "userID", userID) continue @@ -629,19 +637,16 @@ func (p *Plugin) handleDriveWatchNotifications(c *Context, w http.ResponseWriter // Newest activity is at the end of the list so iterate through the list in reverse. for i := len(activities) - 1; i >= 0; i-- { activity := activities[i] + if activity.Timestamp > lastActivityTime { + newLastActivityTime = activity.Timestamp + } if activity.PrimaryActionDetail.Comment != nil { - if activity.Timestamp > lastActivityTime { - newLastActivityTime = activity.Timestamp - } if len(activity.Actors) > 0 && activity.Actors[0].User != nil && activity.Actors[0].User.KnownUser != nil && activity.Actors[0].User.KnownUser.IsCurrentUser { continue } p.handleCommentNotifications(c.Ctx, driveService, change.File, userID, activity) } if activity.PrimaryActionDetail.PermissionChange != nil { - if activity.Timestamp > lastActivityTime { - newLastActivityTime = activity.Timestamp - } if len(activity.Actors) > 0 && activity.Actors[0].User != nil && activity.Actors[0].User.KnownUser != nil && activity.Actors[0].User.KnownUser.IsCurrentUser { continue } diff --git a/server/plugin/config/configuration.go b/server/plugin/config/configuration.go index a5ea76e..effb1d8 100644 --- a/server/plugin/config/configuration.go +++ b/server/plugin/config/configuration.go @@ -24,6 +24,8 @@ type Configuration struct { GoogleOAuthClientID string `json:"googleoauthclientid"` GoogleOAuthClientSecret string `json:"googleoauthclientsecret"` EncryptionKey string `json:"encryptionkey"` + QueriesPerMinute int `json:"queriesperminute"` + BurstSize int `json:"burstsize"` } func (c *Configuration) ToMap() (map[string]interface{}, error) { @@ -89,6 +91,14 @@ func (c *Configuration) IsValid() error { return errors.New("must have an encryption key") } + if c.QueriesPerMinute <= 0 { + return errors.New("queries per minute must be greater than 0") + } + + if c.BurstSize <= 0 { + return errors.New("burst size must be greater than 0") + } + return nil } diff --git a/server/plugin/google/docs.go b/server/plugin/google/docs.go index 3dc2dc6..db6a595 100644 --- a/server/plugin/google/docs.go +++ b/server/plugin/google/docs.go @@ -1,6 +1,8 @@ package google import ( + "context" + "google.golang.org/api/docs/v1" ) @@ -9,10 +11,10 @@ type DocsService struct { googleServiceBase } -func (ds *DocsService) Create(doc *docs.Document) (*docs.Document, error) { +func (ds *DocsService) Create(ctx context.Context, doc *docs.Document) (*docs.Document, error) { d, err := ds.service.Documents.Create(doc).Do() if err != nil { - ds.parseGoogleErrors(err) + ds.parseGoogleErrors(ctx, err) return nil, err } return d, nil diff --git a/server/plugin/google/drive.go b/server/plugin/google/drive.go index 5f3f37a..d47a32b 100644 --- a/server/plugin/google/drive.go +++ b/server/plugin/google/drive.go @@ -28,7 +28,7 @@ func (ds DriveService) About(ctx context.Context, fields googleapi.Field) (*driv da, err := ds.service.About.Get().Fields(fields).Do() if err != nil { - ds.parseGoogleErrors(err) + ds.parseGoogleErrors(ctx, err) return nil, err } return da, nil @@ -42,7 +42,7 @@ func (ds DriveService) WatchChannel(ctx context.Context, startPageToken *drive.S da, err := ds.service.Changes.Watch(startPageToken.StartPageToken, requestChannel).Do() if err != nil { - ds.parseGoogleErrors(err) + ds.parseGoogleErrors(ctx, err) return nil, err } return da, nil @@ -55,7 +55,7 @@ func (ds DriveService) StopChannel(ctx context.Context, channel *drive.Channel) } err = ds.service.Channels.Stop(channel).Do() if err != nil { - ds.parseGoogleErrors(err) + ds.parseGoogleErrors(ctx, err) return err } return nil @@ -68,7 +68,7 @@ func (ds DriveService) ChangesList(ctx context.Context, pageToken string) (*driv } changes, err := ds.service.Changes.List(pageToken).Fields("*").Do() if err != nil { - ds.parseGoogleErrors(err) + ds.parseGoogleErrors(ctx, err) return nil, err } return changes, nil @@ -81,7 +81,7 @@ func (ds DriveService) GetStartPageToken(ctx context.Context) (*drive.StartPageT } tokenResponse, err := ds.service.Changes.GetStartPageToken().Do() if err != nil { - ds.parseGoogleErrors(err) + ds.parseGoogleErrors(ctx, err) return nil, err } return tokenResponse, nil @@ -94,7 +94,7 @@ func (ds DriveService) GetComments(ctx context.Context, fileID string, commentID } comment, err := ds.service.Comments.Get(fileID, commentID).Fields("*").IncludeDeleted(true).Do() if err != nil { - ds.parseGoogleErrors(err) + ds.parseGoogleErrors(ctx, err) return nil, err } return comment, nil @@ -107,7 +107,7 @@ func (ds DriveService) CreateReply(ctx context.Context, fileID string, commentID } googleReply, err := ds.service.Replies.Create(fileID, commentID, reply).Fields("*").Do() if err != nil { - ds.parseGoogleErrors(err) + ds.parseGoogleErrors(ctx, err) return nil, err } return googleReply, nil @@ -120,7 +120,7 @@ func (ds DriveService) CreateFile(ctx context.Context, file *drive.File, fileRea } googleFile, err := ds.service.Files.Create(file).Media(bytes.NewReader(fileReader)).Do() if err != nil { - ds.parseGoogleErrors(err) + ds.parseGoogleErrors(ctx, err) return nil, err } return googleFile, nil @@ -133,7 +133,7 @@ func (ds DriveService) GetFile(ctx context.Context, fileID string) (*drive.File, } file, err := ds.service.Files.Get(fileID).Fields("*").Do() if err != nil { - ds.parseGoogleErrors(err) + ds.parseGoogleErrors(ctx, err) return nil, err } return file, nil @@ -146,7 +146,7 @@ func (ds DriveService) CreatePermission(ctx context.Context, fileID string, perm } googlePermission, err := ds.service.Permissions.Create(fileID, permission).Do() if err != nil { - ds.parseGoogleErrors(err) + ds.parseGoogleErrors(ctx, err) return nil, err } return googlePermission, nil @@ -160,7 +160,7 @@ func (ds DriveServiceV2) About(ctx context.Context, fields googleapi.Field) (*dr da, err := ds.serviceV2.About.Get().Fields(fields).Do() if err != nil { - ds.parseGoogleErrors(err) + ds.parseGoogleErrors(ctx, err) return nil, err } return da, nil diff --git a/server/plugin/google/driveactivity.go b/server/plugin/google/driveactivity.go index 320bbca..f7c8582 100644 --- a/server/plugin/google/driveactivity.go +++ b/server/plugin/google/driveactivity.go @@ -1,6 +1,8 @@ package google import ( + "context" + "google.golang.org/api/driveactivity/v2" ) @@ -9,10 +11,10 @@ type DriveActivityService struct { googleServiceBase } -func (ds *DriveActivityService) Query(request *driveactivity.QueryDriveActivityRequest) (*driveactivity.QueryDriveActivityResponse, error) { +func (ds *DriveActivityService) Query(ctx context.Context, request *driveactivity.QueryDriveActivityRequest) (*driveactivity.QueryDriveActivityResponse, error) { p, err := ds.service.Activity.Query(request).Do() if err != nil { - ds.parseGoogleErrors(err) + ds.parseGoogleErrors(ctx, err) return nil, err } return p, nil diff --git a/server/plugin/google/google.go b/server/plugin/google/google.go index 9174423..4aca564 100644 --- a/server/plugin/google/google.go +++ b/server/plugin/google/google.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "errors" - "time" "github.com/mattermost/mattermost/server/public/plugin" "golang.org/x/oauth2" @@ -49,12 +48,15 @@ const ( ) func NewGoogleClient(oauthConfig *oauth2.Config, config *config.Configuration, kvstore kvstore.KVStore, papi plugin.API) *Client { + maximumQueriesPerSecond := config.QueriesPerMinute / 60 + burstSize := config.BurstSize + return &Client{ oauthConfig: oauthConfig, config: config, kvstore: kvstore, papi: papi, - driveLimiter: rate.NewLimiter(rate.Every(time.Second), 10), + driveLimiter: rate.NewLimiter(rate.Limit(maximumQueriesPerSecond), burstSize), } } @@ -63,11 +65,12 @@ func (g *Client) NewDriveService(ctx context.Context, userID string) (*DriveServ if err != nil { return nil, err } - if !g.driveLimiter.Allow() { - err = g.driveLimiter.WaitN(ctx, 1) - if err != nil { - return nil, err - } + + g.papi.LogDebug("Checking rate limits", "userID", userID, "Limit", g.driveLimiter.Limit(), "Burst", g.driveLimiter.Burst(), "userID", userID) + + err = g.driveLimiter.WaitN(ctx, 1) + if err != nil { + return nil, err } srv, err := drive.NewService(ctx, option.WithTokenSource(g.oauthConfig.TokenSource(ctx, authToken))) @@ -229,7 +232,7 @@ func (g *Client) getGoogleUserToken(userID string) (*oauth2.Token, error) { return &oauthToken, err } -func (ds googleServiceBase) parseGoogleErrors(err error) { +func (ds googleServiceBase) parseGoogleErrors(ctx context.Context, err error) { if googleErr, ok := err.(*googleapi.Error); ok { reason := "" if len(googleErr.Errors) > 0 { @@ -265,7 +268,7 @@ func (ds googleServiceBase) parseGoogleErrors(err error) { ds.papi.LogError("Failed to store user rate limit exceeded", "userID", ds.userID, "err", err) return } - } else if errDetail.Reason == "RATE_LIMIT_EXCEEDED" && errDetail.Metadata.QuotaLimit == "defaultPerMinutePerProject" { + } else { err = ds.kvstore.StoreProjectRateLimitExceeded(ds.serviceType) if err != nil { ds.papi.LogError("Failed to store rate limit exceeded", "err", err) @@ -305,3 +308,8 @@ func (ds googleServiceBase) checkRateLimits(ctx context.Context) error { return nil } + +func (g *Client) ReloadRateLimits(newQueriesPerMinute int, newBurstSize int) { + g.driveLimiter.SetLimit(rate.Limit(newQueriesPerMinute / 60)) + g.driveLimiter.SetBurst(newBurstSize) +} diff --git a/server/plugin/google/sheets.go b/server/plugin/google/sheets.go index f92aded..40a1944 100644 --- a/server/plugin/google/sheets.go +++ b/server/plugin/google/sheets.go @@ -1,6 +1,8 @@ package google import ( + "context" + "google.golang.org/api/sheets/v4" ) @@ -9,10 +11,10 @@ type SheetsService struct { googleServiceBase } -func (ds *SheetsService) Create(spreadsheet *sheets.Spreadsheet) (*sheets.Spreadsheet, error) { +func (ds *SheetsService) Create(ctx context.Context, spreadsheet *sheets.Spreadsheet) (*sheets.Spreadsheet, error) { p, err := ds.service.Spreadsheets.Create(spreadsheet).Do() if err != nil { - ds.parseGoogleErrors(err) + ds.parseGoogleErrors(ctx, err) return nil, err } return p, nil diff --git a/server/plugin/google/slides.go b/server/plugin/google/slides.go index fddc298..f3c7d0f 100644 --- a/server/plugin/google/slides.go +++ b/server/plugin/google/slides.go @@ -1,6 +1,8 @@ package google import ( + "context" + "google.golang.org/api/slides/v1" ) @@ -9,10 +11,10 @@ type SlidesService struct { googleServiceBase } -func (ds *SlidesService) Create(presentation *slides.Presentation) (*slides.Presentation, error) { +func (ds *SlidesService) Create(ctx context.Context, presentation *slides.Presentation) (*slides.Presentation, error) { p, err := ds.service.Presentations.Create(presentation).Do() if err != nil { - ds.parseGoogleErrors(err) + ds.parseGoogleErrors(ctx, err) return nil, err } return p, nil diff --git a/server/plugin/plugin.go b/server/plugin/plugin.go index 26b2b1c..88a05d7 100644 --- a/server/plugin/plugin.go +++ b/server/plugin/plugin.go @@ -283,6 +283,9 @@ func (p *Plugin) OnConfigurationChange() error { p.tracker.ReloadConfig(telemetry.NewTrackerConfig(p.Client.Configuration.GetConfig())) } + if p.GoogleClient != nil { + p.GoogleClient.ReloadRateLimits(configuration.QueriesPerMinute, configuration.BurstSize) + } return nil } From 90d2d14e83f84438cb37d7644d777bad9631475e Mon Sep 17 00:00:00 2001 From: Benjamin Cooke Date: Mon, 21 Oct 2024 23:21:50 -0400 Subject: [PATCH 06/17] cleanup for PR --- server/plugin/google/google.go | 2 -- server/plugin/kvstore/{google_drive.go => google.go} | 0 2 files changed, 2 deletions(-) rename server/plugin/kvstore/{google_drive.go => google.go} (100%) diff --git a/server/plugin/google/google.go b/server/plugin/google/google.go index 4aca564..d773616 100644 --- a/server/plugin/google/google.go +++ b/server/plugin/google/google.go @@ -66,8 +66,6 @@ func (g *Client) NewDriveService(ctx context.Context, userID string) (*DriveServ return nil, err } - g.papi.LogDebug("Checking rate limits", "userID", userID, "Limit", g.driveLimiter.Limit(), "Burst", g.driveLimiter.Burst(), "userID", userID) - err = g.driveLimiter.WaitN(ctx, 1) if err != nil { return nil, err diff --git a/server/plugin/kvstore/google_drive.go b/server/plugin/kvstore/google.go similarity index 100% rename from server/plugin/kvstore/google_drive.go rename to server/plugin/kvstore/google.go From ae56b2a5ff82b4e2e84086768b4b663c4aa8eabf Mon Sep 17 00:00:00 2001 From: Benjamin Cooke Date: Mon, 21 Oct 2024 23:58:07 -0400 Subject: [PATCH 07/17] check rate limits of other services --- server/plugin/google/docs.go | 4 ++ server/plugin/google/driveactivity.go | 4 ++ server/plugin/google/google.go | 64 +++++++++++++++++++++------ server/plugin/google/sheets.go | 4 ++ server/plugin/google/slides.go | 4 ++ 5 files changed, 66 insertions(+), 14 deletions(-) diff --git a/server/plugin/google/docs.go b/server/plugin/google/docs.go index db6a595..136035d 100644 --- a/server/plugin/google/docs.go +++ b/server/plugin/google/docs.go @@ -12,6 +12,10 @@ type DocsService struct { } func (ds *DocsService) Create(ctx context.Context, doc *docs.Document) (*docs.Document, error) { + err := ds.checkRateLimits(ctx) + if err != nil { + return nil, err + } d, err := ds.service.Documents.Create(doc).Do() if err != nil { ds.parseGoogleErrors(ctx, err) diff --git a/server/plugin/google/driveactivity.go b/server/plugin/google/driveactivity.go index f7c8582..52f8253 100644 --- a/server/plugin/google/driveactivity.go +++ b/server/plugin/google/driveactivity.go @@ -12,6 +12,10 @@ type DriveActivityService struct { } func (ds *DriveActivityService) Query(ctx context.Context, request *driveactivity.QueryDriveActivityRequest) (*driveactivity.QueryDriveActivityResponse, error) { + err := ds.checkRateLimits(ctx) + if err != nil { + return nil, err + } p, err := ds.service.Activity.Query(request).Do() if err != nil { ds.parseGoogleErrors(ctx, err) diff --git a/server/plugin/google/google.go b/server/plugin/google/google.go index d773616..5e9c5e0 100644 --- a/server/plugin/google/google.go +++ b/server/plugin/google/google.go @@ -66,6 +66,11 @@ func (g *Client) NewDriveService(ctx context.Context, userID string) (*DriveServ return nil, err } + err = checkKVStoreLimitExceeded(g.kvstore, driveServiceType, userID) + if err != nil { + return nil, err + } + err = g.driveLimiter.WaitN(ctx, 1) if err != nil { return nil, err @@ -93,11 +98,15 @@ func (g *Client) NewDriveV2Service(ctx context.Context, userID string) (*DriveSe if err != nil { return nil, err } - if !g.driveLimiter.Allow() { - err = g.driveLimiter.WaitN(ctx, 1) - if err != nil { - return nil, err - } + + err = checkKVStoreLimitExceeded(g.kvstore, driveServiceType, userID) + if err != nil { + return nil, err + } + + err = g.driveLimiter.WaitN(ctx, 1) + if err != nil { + return nil, err } srv, err := driveV2.NewService(ctx, option.WithTokenSource(g.oauthConfig.TokenSource(ctx, authToken))) @@ -123,6 +132,11 @@ func (g *Client) NewDocsService(ctx context.Context, userID string) (*DocsServic return nil, err } + err = checkKVStoreLimitExceeded(g.kvstore, docsServiceType, userID) + if err != nil { + return nil, err + } + srv, err := docs.NewService(ctx, option.WithTokenSource(g.oauthConfig.TokenSource(ctx, authToken))) if err != nil { return nil, err @@ -146,6 +160,11 @@ func (g *Client) NewSlidesService(ctx context.Context, userID string) (*SlidesSe return nil, err } + err = checkKVStoreLimitExceeded(g.kvstore, slidesServiceType, userID) + if err != nil { + return nil, err + } + srv, err := slides.NewService(ctx, option.WithTokenSource(g.oauthConfig.TokenSource(ctx, authToken))) if err != nil { return nil, err @@ -169,6 +188,11 @@ func (g *Client) NewSheetsService(ctx context.Context, userID string) (*SheetsSe return nil, err } + err = checkKVStoreLimitExceeded(g.kvstore, sheetsServiceType, userID) + if err != nil { + return nil, err + } + srv, err := sheets.NewService(ctx, option.WithTokenSource(g.oauthConfig.TokenSource(ctx, authToken))) if err != nil { return nil, err @@ -192,6 +216,11 @@ func (g *Client) NewDriveActivityService(ctx context.Context, userID string) (*D return nil, err } + err = checkKVStoreLimitExceeded(g.kvstore, driveActivityServiceType, userID) + if err != nil { + return nil, err + } + srv, err := driveactivity.NewService(ctx, option.WithTokenSource(g.oauthConfig.TokenSource(ctx, authToken))) if err != nil { return nil, err @@ -279,30 +308,37 @@ func (ds googleServiceBase) parseGoogleErrors(ctx context.Context, err error) { } } -func (ds googleServiceBase) checkRateLimits(ctx context.Context) error { - if ds.limiter == nil { - return nil - } - userIsRateLimited, err := ds.kvstore.GetUserRateLimitExceeded(ds.serviceType, ds.userID) +func checkKVStoreLimitExceeded(kv kvstore.KVStore, serviceType string, userID string) error { + userIsRateLimited, err := kv.GetUserRateLimitExceeded(serviceType, userID) if err != nil { return err } if userIsRateLimited { - return errors.New("user rate limit exceeded") + return errors.New("user rate limit exceeded for Google service: " + serviceType) } - projectIsRateLimited, err := ds.kvstore.GetProjectRateLimitExceeded(ds.serviceType) + projectIsRateLimited, err := kv.GetProjectRateLimitExceeded(serviceType) if err != nil { return err } if projectIsRateLimited { - return errors.New("project rate limit exceeded") + return errors.New("project rate limit exceeded for Google service: " + serviceType) } - err = ds.limiter.WaitN(ctx, 1) + return nil +} + +func (ds googleServiceBase) checkRateLimits(ctx context.Context) error { + err := checkKVStoreLimitExceeded(ds.kvstore, ds.serviceType, ds.userID) if err != nil { return err } + if ds.limiter != nil { + err = ds.limiter.WaitN(ctx, 1) + if err != nil { + return err + } + } return nil } diff --git a/server/plugin/google/sheets.go b/server/plugin/google/sheets.go index 40a1944..982e863 100644 --- a/server/plugin/google/sheets.go +++ b/server/plugin/google/sheets.go @@ -12,6 +12,10 @@ type SheetsService struct { } func (ds *SheetsService) Create(ctx context.Context, spreadsheet *sheets.Spreadsheet) (*sheets.Spreadsheet, error) { + err := ds.checkRateLimits(ctx) + if err != nil { + return nil, err + } p, err := ds.service.Spreadsheets.Create(spreadsheet).Do() if err != nil { ds.parseGoogleErrors(ctx, err) diff --git a/server/plugin/google/slides.go b/server/plugin/google/slides.go index f3c7d0f..01e6883 100644 --- a/server/plugin/google/slides.go +++ b/server/plugin/google/slides.go @@ -12,6 +12,10 @@ type SlidesService struct { } func (ds *SlidesService) Create(ctx context.Context, presentation *slides.Presentation) (*slides.Presentation, error) { + err := ds.checkRateLimits(ctx) + if err != nil { + return nil, err + } p, err := ds.service.Presentations.Create(presentation).Do() if err != nil { ds.parseGoogleErrors(ctx, err) From e08b5b802c1245890ff9b03649d30af8cfd14c4e Mon Sep 17 00:00:00 2001 From: Benjamin Cooke Date: Wed, 23 Oct 2024 14:26:02 -0400 Subject: [PATCH 08/17] clean up some duplicate logs --- server/plugin/api.go | 21 ++++-------- server/plugin/create.go | 13 +++----- server/plugin/google/docs.go | 2 +- server/plugin/google/drive.go | 22 ++++++------ server/plugin/google/driveactivity.go | 2 +- server/plugin/google/google.go | 23 ++++++------- server/plugin/google/sheets.go | 2 +- server/plugin/google/slides.go | 2 +- server/plugin/notifications.go | 48 ++++++++++++--------------- 9 files changed, 59 insertions(+), 76 deletions(-) diff --git a/server/plugin/api.go b/server/plugin/api.go index f2f0daf..959cc14 100644 --- a/server/plugin/api.go +++ b/server/plugin/api.go @@ -47,10 +47,6 @@ type Context struct { Log logger.Logger } -type UserContext struct { - Context -} - type FileCreationRequest struct { Name string `json:"name"` FileAccess string `json:"file_access"` @@ -74,9 +70,6 @@ type DialogErrorResponse struct { StatusCode int `json:"status_code"` } -// HTTPHandlerFuncWithUserContext is http.HandleFunc but with a UserContext attached -type HTTPHandlerFuncWithUserContext func(c *UserContext, w http.ResponseWriter, r *http.Request) - // HTTPHandlerFuncWithContext is http.HandleFunc but with a Context attached type HTTPHandlerFuncWithContext func(c *Context, w http.ResponseWriter, r *http.Request) @@ -378,7 +371,7 @@ func getRawRequestAndFileCreationParams(r *http.Request) (*FileCreationRequest, func (p *Plugin) handleFileCreation(c *Context, w http.ResponseWriter, r *http.Request) { fileCreationParams, request, err := getRawRequestAndFileCreationParams(r) if err != nil { - p.API.LogError("Failed to get fileCreationParams", "err", err) + c.Log.WithError(err).Errorf("Failed to get fileCreationParams") p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusBadRequest}) return } @@ -391,7 +384,7 @@ func (p *Plugin) handleFileCreation(c *Context, w http.ResponseWriter, r *http.R { srv, dErr := p.GoogleClient.NewDocsService(c.Ctx, c.UserID) if dErr != nil { - p.API.LogError("Failed to create Google Docs client", "err", dErr) + c.Log.WithError(dErr).Errorf("Failed to create Google Docs client") p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusInternalServerError}) return } @@ -408,7 +401,7 @@ func (p *Plugin) handleFileCreation(c *Context, w http.ResponseWriter, r *http.R { srv, dErr := p.GoogleClient.NewSlidesService(c.Ctx, c.UserID) if dErr != nil { - p.API.LogError("Failed to create Google Slides client", "err", dErr) + c.Log.WithError(dErr).Errorf("Failed to create Google Slides client") p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusInternalServerError}) return } @@ -425,7 +418,7 @@ func (p *Plugin) handleFileCreation(c *Context, w http.ResponseWriter, r *http.R { srv, dErr := p.GoogleClient.NewSheetsService(c.Ctx, c.UserID) if dErr != nil { - p.API.LogError("Failed to create Google Sheets client", "err", dErr) + c.Log.WithError(dErr).Errorf("Failed to create Google Sheets client") p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusInternalServerError}) return } @@ -443,20 +436,20 @@ func (p *Plugin) handleFileCreation(c *Context, w http.ResponseWriter, r *http.R } if fileCreationErr != nil { - p.API.LogError("Failed to create Google Drive file", "err", fileCreationErr) + c.Log.WithError(fileCreationErr).Errorf("Failed to create Google Drive file") p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusInternalServerError}) return } err = p.handleFilePermissions(c.Ctx, c.UserID, createdFileID, fileCreationParams.FileAccess, request.ChannelId, fileCreationParams.Name) if err != nil { - p.API.LogError("Failed to modify file permissions", "err", err) + c.Log.WithError(err).Errorf("Failed to modify file permissions") p.writeInteractiveDialogError(w, DialogErrorResponse{Error: "File was successfully created but file permissions failed to apply. Please contact your system administrator.", StatusCode: http.StatusInternalServerError}) return } err = p.sendFileCreatedMessage(c.Ctx, request.ChannelId, createdFileID, c.UserID, fileCreationParams.Message, fileCreationParams.ShareInChannel) if err != nil { - p.API.LogError("Failed to send file creation post", "err", err) + c.Log.WithError(err).Errorf("Failed to send file creation post") p.writeInteractiveDialogError(w, DialogErrorResponse{Error: "File was successfully created but failed to share to the channel. Please contact your system administrator.", StatusCode: http.StatusInternalServerError}) return } diff --git a/server/plugin/create.go b/server/plugin/create.go index 3ad6ac0..b7edf67 100644 --- a/server/plugin/create.go +++ b/server/plugin/create.go @@ -2,12 +2,13 @@ package plugin import ( "context" - "errors" "fmt" "slices" "strings" "time" + "github.com/pkg/errors" + "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/plugin" "golang.org/x/text/cases" @@ -18,13 +19,11 @@ import ( func (p *Plugin) sendFileCreatedMessage(ctx context.Context, channelID, fileID, userID, message string, shareInChannel bool) error { driveService, err := p.GoogleClient.NewDriveService(ctx, userID) if err != nil { - p.API.LogError("Failed to create Google Drive service", "err", err, "userID", userID) - return err + return errors.Wrap(err, "failed to create Google Drive service") } file, err := driveService.GetFile(ctx, fileID) if err != nil { - p.API.LogError("Failed to fetch file", "err", err, "fileID", fileID) - return err + return errors.Wrap(err, "failed to fetch file") } createdTime, _ := time.Parse(time.RFC3339, file.CreatedTime) @@ -128,8 +127,7 @@ func (p *Plugin) handleFilePermissions(ctx context.Context, userID string, fileI driveService, err := p.GoogleClient.NewDriveService(ctx, userID) if err != nil { - p.API.LogError("Failed to create Google Drive client", "err", err) - return err + return errors.Wrap(err, "failed to create Google Drive service") } usersWithoutAccesss := []string{} @@ -149,7 +147,6 @@ func (p *Plugin) handleFilePermissions(ctx context.Context, userID string, fileI if strings.Contains(err.Error(), "shareOutNotPermitted") { continue } - p.API.LogError("Something went wrong while updating permissions for file", "err", err, "fileID", fileID) permissionError = err } } diff --git a/server/plugin/google/docs.go b/server/plugin/google/docs.go index 136035d..f5c8658 100644 --- a/server/plugin/google/docs.go +++ b/server/plugin/google/docs.go @@ -18,7 +18,7 @@ func (ds *DocsService) Create(ctx context.Context, doc *docs.Document) (*docs.Do } d, err := ds.service.Documents.Create(doc).Do() if err != nil { - ds.parseGoogleErrors(ctx, err) + err = ds.parseGoogleErrors(ctx, err) return nil, err } return d, nil diff --git a/server/plugin/google/drive.go b/server/plugin/google/drive.go index d47a32b..9ede922 100644 --- a/server/plugin/google/drive.go +++ b/server/plugin/google/drive.go @@ -28,7 +28,7 @@ func (ds DriveService) About(ctx context.Context, fields googleapi.Field) (*driv da, err := ds.service.About.Get().Fields(fields).Do() if err != nil { - ds.parseGoogleErrors(ctx, err) + err = ds.parseGoogleErrors(ctx, err) return nil, err } return da, nil @@ -42,7 +42,7 @@ func (ds DriveService) WatchChannel(ctx context.Context, startPageToken *drive.S da, err := ds.service.Changes.Watch(startPageToken.StartPageToken, requestChannel).Do() if err != nil { - ds.parseGoogleErrors(ctx, err) + err = ds.parseGoogleErrors(ctx, err) return nil, err } return da, nil @@ -55,7 +55,7 @@ func (ds DriveService) StopChannel(ctx context.Context, channel *drive.Channel) } err = ds.service.Channels.Stop(channel).Do() if err != nil { - ds.parseGoogleErrors(ctx, err) + err = ds.parseGoogleErrors(ctx, err) return err } return nil @@ -68,7 +68,7 @@ func (ds DriveService) ChangesList(ctx context.Context, pageToken string) (*driv } changes, err := ds.service.Changes.List(pageToken).Fields("*").Do() if err != nil { - ds.parseGoogleErrors(ctx, err) + err = ds.parseGoogleErrors(ctx, err) return nil, err } return changes, nil @@ -81,7 +81,7 @@ func (ds DriveService) GetStartPageToken(ctx context.Context) (*drive.StartPageT } tokenResponse, err := ds.service.Changes.GetStartPageToken().Do() if err != nil { - ds.parseGoogleErrors(ctx, err) + err = ds.parseGoogleErrors(ctx, err) return nil, err } return tokenResponse, nil @@ -94,7 +94,7 @@ func (ds DriveService) GetComments(ctx context.Context, fileID string, commentID } comment, err := ds.service.Comments.Get(fileID, commentID).Fields("*").IncludeDeleted(true).Do() if err != nil { - ds.parseGoogleErrors(ctx, err) + err = ds.parseGoogleErrors(ctx, err) return nil, err } return comment, nil @@ -107,7 +107,7 @@ func (ds DriveService) CreateReply(ctx context.Context, fileID string, commentID } googleReply, err := ds.service.Replies.Create(fileID, commentID, reply).Fields("*").Do() if err != nil { - ds.parseGoogleErrors(ctx, err) + err = ds.parseGoogleErrors(ctx, err) return nil, err } return googleReply, nil @@ -120,7 +120,7 @@ func (ds DriveService) CreateFile(ctx context.Context, file *drive.File, fileRea } googleFile, err := ds.service.Files.Create(file).Media(bytes.NewReader(fileReader)).Do() if err != nil { - ds.parseGoogleErrors(ctx, err) + err = ds.parseGoogleErrors(ctx, err) return nil, err } return googleFile, nil @@ -133,7 +133,7 @@ func (ds DriveService) GetFile(ctx context.Context, fileID string) (*drive.File, } file, err := ds.service.Files.Get(fileID).Fields("*").Do() if err != nil { - ds.parseGoogleErrors(ctx, err) + err = ds.parseGoogleErrors(ctx, err) return nil, err } return file, nil @@ -146,7 +146,7 @@ func (ds DriveService) CreatePermission(ctx context.Context, fileID string, perm } googlePermission, err := ds.service.Permissions.Create(fileID, permission).Do() if err != nil { - ds.parseGoogleErrors(ctx, err) + err = ds.parseGoogleErrors(ctx, err) return nil, err } return googlePermission, nil @@ -160,7 +160,7 @@ func (ds DriveServiceV2) About(ctx context.Context, fields googleapi.Field) (*dr da, err := ds.serviceV2.About.Get().Fields(fields).Do() if err != nil { - ds.parseGoogleErrors(ctx, err) + err = ds.parseGoogleErrors(ctx, err) return nil, err } return da, nil diff --git a/server/plugin/google/driveactivity.go b/server/plugin/google/driveactivity.go index 52f8253..e3cf144 100644 --- a/server/plugin/google/driveactivity.go +++ b/server/plugin/google/driveactivity.go @@ -18,7 +18,7 @@ func (ds *DriveActivityService) Query(ctx context.Context, request *driveactivit } p, err := ds.service.Activity.Query(request).Do() if err != nil { - ds.parseGoogleErrors(ctx, err) + err = ds.parseGoogleErrors(ctx, err) return nil, err } return p, nil diff --git a/server/plugin/google/google.go b/server/plugin/google/google.go index 5e9c5e0..f48b5f0 100644 --- a/server/plugin/google/google.go +++ b/server/plugin/google/google.go @@ -259,8 +259,8 @@ func (g *Client) getGoogleUserToken(userID string) (*oauth2.Token, error) { return &oauthToken, err } -func (ds googleServiceBase) parseGoogleErrors(ctx context.Context, err error) { - if googleErr, ok := err.(*googleapi.Error); ok { +func (ds googleServiceBase) parseGoogleErrors(ctx context.Context, apiErr error) error { + if googleErr, ok := apiErr.(*googleapi.Error); ok { reason := "" if len(googleErr.Errors) > 0 { for _, error := range googleErr.Errors { @@ -271,10 +271,9 @@ func (ds googleServiceBase) parseGoogleErrors(ctx context.Context, err error) { } } if reason == "userRateLimitExceeded" { - err = ds.kvstore.StoreUserRateLimitExceeded(ds.serviceType, ds.userID) + err := ds.kvstore.StoreUserRateLimitExceeded(ds.serviceType, ds.userID) if err != nil { - ds.papi.LogError("Failed to store user rate limit exceeded", "userID", ds.userID, "err", err) - return + return errors.Join(apiErr, err) } } if reason == "rateLimitExceeded" && len(googleErr.Details) > 0 { @@ -283,29 +282,29 @@ func (ds googleServiceBase) parseGoogleErrors(ctx context.Context, err error) { var errDetail *model.ErrorDetail jsonErr := json.Unmarshal(byteData, &errDetail) if jsonErr != nil { - ds.papi.LogError("Failed to parse error details", "err", jsonErr) + ds.papi.LogWarn("Failed to parse error details", "err", jsonErr) continue } if errDetail != nil { // Even if the original "reason" is rateLimitExceeded, we need to check the QuotaLimit field in the metadata because it might only apply to this specific user. if errDetail.Reason == "RATE_LIMIT_EXCEEDED" && errDetail.Metadata.QuotaLimit == "defaultPerMinutePerUser" { - err = ds.kvstore.StoreUserRateLimitExceeded(ds.serviceType, ds.userID) + err := ds.kvstore.StoreUserRateLimitExceeded(ds.serviceType, ds.userID) if err != nil { - ds.papi.LogError("Failed to store user rate limit exceeded", "userID", ds.userID, "err", err) - return + return errors.Join(apiErr, err) } } else { - err = ds.kvstore.StoreProjectRateLimitExceeded(ds.serviceType) + err := ds.kvstore.StoreProjectRateLimitExceeded(ds.serviceType) if err != nil { - ds.papi.LogError("Failed to store rate limit exceeded", "err", err) - return + return errors.Join(apiErr, err) } } } } } } + + return apiErr } func checkKVStoreLimitExceeded(kv kvstore.KVStore, serviceType string, userID string) error { diff --git a/server/plugin/google/sheets.go b/server/plugin/google/sheets.go index 982e863..ba60df1 100644 --- a/server/plugin/google/sheets.go +++ b/server/plugin/google/sheets.go @@ -18,7 +18,7 @@ func (ds *SheetsService) Create(ctx context.Context, spreadsheet *sheets.Spreads } p, err := ds.service.Spreadsheets.Create(spreadsheet).Do() if err != nil { - ds.parseGoogleErrors(ctx, err) + err = ds.parseGoogleErrors(ctx, err) return nil, err } return p, nil diff --git a/server/plugin/google/slides.go b/server/plugin/google/slides.go index 01e6883..7359a2a 100644 --- a/server/plugin/google/slides.go +++ b/server/plugin/google/slides.go @@ -18,7 +18,7 @@ func (ds *SlidesService) Create(ctx context.Context, presentation *slides.Presen } p, err := ds.service.Presentations.Create(presentation).Do() if err != nil { - ds.parseGoogleErrors(ctx, err) + err = ds.parseGoogleErrors(ctx, err) return nil, err } return p, nil diff --git a/server/plugin/notifications.go b/server/plugin/notifications.go index 54fc66d..ba87a7f 100644 --- a/server/plugin/notifications.go +++ b/server/plugin/notifications.go @@ -10,6 +10,7 @@ import ( "github.com/google/uuid" mattermostModel "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/plugin" + "github.com/pkg/errors" "google.golang.org/api/drive/v3" "google.golang.org/api/driveactivity/v2" @@ -18,17 +19,24 @@ import ( "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/utils" ) -func (p *Plugin) handleAddedComment(ctx context.Context, dSrv *google.DriveService, fileID, userID string, activity *driveactivity.DriveActivity, file *drive.File) { +func getCommentUsingDiscussionID(ctx context.Context, dSrv *google.DriveService, fileID string, activity *driveactivity.DriveActivity) (*drive.Comment, error) { if len(activity.Targets) == 0 || activity.Targets[0].FileComment == nil || - activity.Targets[0].FileComment.LegacyCommentId == "" { - p.API.LogWarn("There is no legacyCommentId present in the activity") - return + activity.Targets[0].FileComment.LegacyDiscussionId == "" { + return nil, errors.New("no legacyDiscussionId present in the activity") } commentID := activity.Targets[0].FileComment.LegacyDiscussionId comment, err := dSrv.GetComments(ctx, fileID, commentID) if err != nil { - p.API.LogError("Failed to get comment by legacyDiscussionId", "err", err, "commentID", commentID) + return nil, err + } + return comment, nil +} + +func (p *Plugin) handleAddedComment(ctx context.Context, dSrv *google.DriveService, fileID, userID string, activity *driveactivity.DriveActivity, file *drive.File) { + comment, err := getCommentUsingDiscussionID(ctx, dSrv, fileID, activity) + if err != nil { + p.API.LogError("Failed to get comment by legacyDiscussionId", "err", err, "userID", userID) return } quotedValue := "" @@ -46,7 +54,7 @@ func (p *Plugin) handleAddedComment(ctx context.Context, dSrv *google.DriveServi "integration": map[string]any{ "url": fmt.Sprintf("%s/plugins/%s/api/v1/reply_dialog", *p.API.GetConfig().ServiceSettings.SiteURL, Manifest.Id), "context": map[string]any{ - "commentID": commentID, + "commentID": comment.Id, "fileID": fileID, }, }, @@ -65,16 +73,9 @@ func (p *Plugin) handleDeletedComment(userID string, activity *driveactivity.Dri } func (p *Plugin) handleReplyAdded(ctx context.Context, dSrv *google.DriveService, fileID, userID string, activity *driveactivity.DriveActivity, file *drive.File) { - if len(activity.Targets) == 0 || - activity.Targets[0].FileComment == nil || - activity.Targets[0].FileComment.LegacyDiscussionId == "" { - p.API.LogWarn("There is no legacyDiscussionId present in the activity") - return - } - commentID := activity.Targets[0].FileComment.LegacyDiscussionId - comment, err := dSrv.GetComments(ctx, fileID, commentID) + comment, err := getCommentUsingDiscussionID(ctx, dSrv, fileID, activity) if err != nil { - p.API.LogError("Failed to get comment by legacyDiscussionId", "err", err, "commentID", commentID) + p.API.LogError("Failed to get comment by legacyDiscussionId", "err", err, "userID", userID) return } urlToComment := activity.Targets[0].FileComment.LinkToDiscussion @@ -101,7 +102,7 @@ func (p *Plugin) handleReplyAdded(ctx context.Context, dSrv *google.DriveService "integration": map[string]any{ "url": fmt.Sprintf("%s/plugins/%s/api/v1/reply_dialog", *p.API.GetConfig().ServiceSettings.SiteURL, Manifest.Id), "context": map[string]any{ - "commentID": commentID, + "commentID": comment.Id, "fileID": fileID, }, }, @@ -123,13 +124,13 @@ func (p *Plugin) handleResolvedComment(ctx context.Context, dSrv *google.DriveSe if len(activity.Targets) == 0 || activity.Targets[0].FileComment == nil || activity.Targets[0].FileComment.LegacyCommentId == "" { - p.API.LogWarn("There is no legacyCommentId present in the activity") + p.API.LogWarn("There is no legacyCommentId present in the activity", "userID", userID) return } commentID := activity.Targets[0].FileComment.LegacyCommentId comment, err := dSrv.GetComments(ctx, fileID, commentID) if err != nil { - p.API.LogError("Failed to get comment by legacyCommentId", "err", err, "commentID", commentID) + p.API.LogError("Failed to get comment by legacyCommentId", "err", err, "commentID", commentID, "userID", userID) return } urlToComment := activity.Targets[0].FileComment.LinkToDiscussion @@ -138,16 +139,9 @@ func (p *Plugin) handleResolvedComment(ctx context.Context, dSrv *google.DriveSe } func (p *Plugin) handleReopenedComment(ctx context.Context, dSrv *google.DriveService, fileID, userID string, activity *driveactivity.DriveActivity, file *drive.File) { - if len(activity.Targets) == 0 || - activity.Targets[0].FileComment == nil || - activity.Targets[0].FileComment.LegacyDiscussionId == "" { - p.API.LogWarn("There is no legacyDiscussionId present in the activity") - return - } - commentID := activity.Targets[0].FileComment.LegacyDiscussionId - comment, err := dSrv.GetComments(ctx, fileID, commentID) + comment, err := getCommentUsingDiscussionID(ctx, dSrv, fileID, activity) if err != nil { - p.API.LogError("Failed to get comment by legacyDiscussionId", "err", err, "commentID", commentID) + p.API.LogError("Failed to get comment by legacyDiscussionId", "err", err, "userID", userID) return } urlToComment := activity.Targets[0].FileComment.LinkToDiscussion From 84980ad651078742972b42f44c51ed209996f87c Mon Sep 17 00:00:00 2001 From: Benjamin Cooke Date: Thu, 24 Oct 2024 17:01:49 -0400 Subject: [PATCH 09/17] cap the number of calls to google apis in areas where we could end up spamming; --- server/plugin/api.go | 80 ++++++++++++++++++++++------------ server/plugin/create.go | 4 +- server/plugin/notifications.go | 11 +++++ 3 files changed, 66 insertions(+), 29 deletions(-) diff --git a/server/plugin/api.go b/server/plugin/api.go index 959cc14..3f16a88 100644 --- a/server/plugin/api.go +++ b/server/plugin/api.go @@ -523,7 +523,12 @@ func (p *Plugin) handleDriveWatchNotifications(c *Context, w http.ResponseWriter var pageTokenErr error var changes []*drive.Change + i := 0 for { + // Cap this loop at 5 iterations to prevent unbounded calls to the Google Drive API. + if i == 5 { + break + } changeList, changeErr := driveService.ChangesList(c.Ctx, pageToken) if changeErr != nil { p.API.LogError("Failed to fetch Google Drive changes", "err", changeErr, "userID", userID) @@ -531,13 +536,14 @@ func (p *Plugin) handleDriveWatchNotifications(c *Context, w http.ResponseWriter return } changes = append(changes, changeList.Changes...) - // NewStartPageToken will be empty if there is another page of results. This should only happen if this user changed over 20/30 files at once. There is no definitive number. + // NewStartPageToken will be empty if there is another page of results. This should only happen if this user changed over 20/30 files at once. There is no definitive number of changes that will be returned. if changeList.NewStartPageToken != "" { // Updated pageToken gets saved at the end along with the new FileLastActivity. pageToken = changeList.NewStartPageToken break } pageToken = changeList.NextPageToken + i++ } defer func() { @@ -570,9 +576,21 @@ func (p *Plugin) handleDriveWatchNotifications(c *Context, w http.ResponseWriter continue } - modifiedTime, _ := time.Parse(time.RFC3339, change.File.ModifiedTime) - lastChangeTime, _ := time.Parse(time.RFC3339, change.Time) - viewedByMeTime, _ := time.Parse(time.RFC3339, change.File.ViewedByMeTime) + modifiedTime, err := time.Parse(time.RFC3339, change.File.ModifiedTime) + if err != nil { + p.API.LogError("Failed to parse modified time", "err", err, "userID", userID) + continue + } + lastChangeTime, err := time.Parse(time.RFC3339, change.Time) + if err != nil { + p.API.LogError("Failed to parse last change time", "err", err, "userID", userID) + continue + } + viewedByMeTime, err := time.Parse(time.RFC3339, change.File.ViewedByMeTime) + if err != nil { + p.API.LogError("Failed to parse viewed by me time", "err", err, "userID", userID) + continue + } // Check if the user has already opened the file after the last change. if lastChangeTime.Sub(modifiedTime) >= lastChangeTime.Sub(viewedByMeTime) { @@ -607,51 +625,59 @@ func (p *Plugin) handleDriveWatchNotifications(c *Context, w http.ResponseWriter } var activities []*driveactivity.DriveActivity + i = 0 for { + // Cap this loop at 5 iterations to prevent unbounded calls to the Google Drive Activity API. + if i == 5 { + break + } var activityRes *driveactivity.QueryDriveActivityResponse activityRes, err = activitySrv.Query(c.Ctx, driveActivityQuery) if err != nil { p.API.LogError("Failed to fetch google drive activity", "err", err, "fileID", change.FileId, "userID", userID) continue } - activities = append(activities, activityRes.Activities...) + for _, activity := range activityRes.Activities { + if activity.PrimaryActionDetail.Comment != nil || activity.PrimaryActionDetail.PermissionChange != nil { + if len(activity.Actors) > 0 && activity.Actors[0].User != nil && activity.Actors[0].User.KnownUser != nil && activity.Actors[0].User.KnownUser.IsCurrentUser { + continue + } + activities = append(activities, activity) + } + } // NextPageToken is set when there are more than 1 page of activities for a file. We don't want the next page token if we are only fetching the latest activity. - if activityRes.NextPageToken != "" && driveActivityQuery.PageSize != 1 { + if (activityRes.NextPageToken != "" && driveActivityQuery.PageSize != 1) || (activityRes.NextPageToken != "" && len(activities) <= 5) { driveActivityQuery.PageToken = activityRes.NextPageToken } else { break } + i++ } if len(activities) == 0 { continue } - newLastActivityTime := lastActivityTime - // Newest activity is at the end of the list so iterate through the list in reverse. - for i := len(activities) - 1; i >= 0; i-- { - activity := activities[i] - if activity.Timestamp > lastActivityTime { - newLastActivityTime = activity.Timestamp - } - if activity.PrimaryActionDetail.Comment != nil { - if len(activity.Actors) > 0 && activity.Actors[0].User != nil && activity.Actors[0].User.KnownUser != nil && activity.Actors[0].User.KnownUser.IsCurrentUser { - continue + + // We don't want to spam the user with notifications if there are more than 5 activities. + if len(activities) > 5 { + p.handleMultipleActivitiesNotification(change.File, userID) + } else { + // Newest activity is at the end of the list so iterate through the list in reverse. + for i := len(activities) - 1; i >= 0; i-- { + activity := activities[i] + if activity.PrimaryActionDetail.Comment != nil { + p.handleCommentNotifications(c.Ctx, driveService, change.File, userID, activity) } - p.handleCommentNotifications(c.Ctx, driveService, change.File, userID, activity) - } - if activity.PrimaryActionDetail.PermissionChange != nil { - if len(activity.Actors) > 0 && activity.Actors[0].User != nil && activity.Actors[0].User.KnownUser != nil && activity.Actors[0].User.KnownUser.IsCurrentUser { - continue + + if activity.PrimaryActionDetail.PermissionChange != nil { + p.handleFileSharedNotification(change.File, userID) } - p.handleFileSharedNotification(change.File, userID) } } - if newLastActivityTime > lastActivityTime { - err = p.KVStore.StoreLastActivityForFile(userID, change.FileId, newLastActivityTime) - if err != nil { - p.API.LogError("Failed to store last activity for file", "err", err, "fileID", change.FileId, "userID", userID) - } + err = p.KVStore.StoreLastActivityForFile(userID, change.FileId, change.File.ModifiedTime) + if err != nil { + p.API.LogError("Failed to store last activity for file", "err", err, "fileID", change.FileId, "userID", userID) } } diff --git a/server/plugin/create.go b/server/plugin/create.go index b7edf67..449804d 100644 --- a/server/plugin/create.go +++ b/server/plugin/create.go @@ -134,9 +134,9 @@ func (p *Plugin) handleFilePermissions(ctx context.Context, userID string, fileI config := p.API.GetConfig() var permissionError error - for _, permission := range permissions { + for i, permission := range permissions { // Continue through the permissions loop when we encounter an error so we can inform the user who wasn't granted access. - if permissionError != nil { + if permissionError != nil || i > 60 { usersWithoutAccesss = appendUsersWithoutAccessSlice(config, usersWithoutAccesss, userMap[permission.EmailAddress].Username, permission.EmailAddress) continue } diff --git a/server/plugin/notifications.go b/server/plugin/notifications.go index ba87a7f..6f90ba6 100644 --- a/server/plugin/notifications.go +++ b/server/plugin/notifications.go @@ -202,6 +202,17 @@ func (p *Plugin) handleFileSharedNotification(file *drive.File, userID string) { }) } +func (p *Plugin) handleMultipleActivitiesNotification(file *drive.File, userID string) { + p.createBotDMPost(userID, "There has been activity on this document", map[string]any{ + "attachments": []any{map[string]any{ + "title": file.Name, + "title_link": file.WebViewLink, + "footer": "Google Drive for Mattermost", + "footer_icon": file.IconLink, + }}, + }) +} + func (p *Plugin) startDriveWatchChannel(userID string) error { ctx := context.Background() driveService, err := p.GoogleClient.NewDriveService(ctx, userID) From fd459179e1cc1c4e323e6808e62e9eaf87cea70b Mon Sep 17 00:00:00 2001 From: Benjamin Cooke Date: Mon, 28 Oct 2024 12:37:49 -0400 Subject: [PATCH 10/17] remove accidental addition --- server/plugin/api.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/server/plugin/api.go b/server/plugin/api.go index 3f16a88..49bee3d 100644 --- a/server/plugin/api.go +++ b/server/plugin/api.go @@ -13,7 +13,6 @@ import ( "github.com/gorilla/mux" mattermostModel "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/plugin" - "github.com/mattermost/mattermost/server/public/pluginapi" "github.com/mattermost/mattermost/server/public/pluginapi/cluster" "github.com/mattermost/mattermost/server/public/pluginapi/experimental/bot/logger" "github.com/mattermost/mattermost/server/public/pluginapi/experimental/flow" @@ -459,7 +458,6 @@ func (p *Plugin) handleDriveWatchNotifications(c *Context, w http.ResponseWriter resourceState := r.Header.Get("X-Goog-Resource-State") userID := r.URL.Query().Get("userID") - _, _ = p.Client.KV.Set("userID-"+userID, userID, pluginapi.SetExpiry(20)) if resourceState != "change" { w.WriteHeader(http.StatusOK) return From 03309d865d66e5f53e913c70733e5d4d0f6d302a Mon Sep 17 00:00:00 2001 From: Benjamin Cooke Date: Tue, 29 Oct 2024 16:09:37 -0400 Subject: [PATCH 11/17] adding initial mocks and some untidy tests to ensure mocks are set up properly --- go.mod | 10 +- go.sum | 15 + server/plugin/api.go | 9 +- server/plugin/api_test.go | 165 +++++++++++ server/plugin/google/google.go | 22 +- server/plugin/google/interfaces.go | 43 +++ server/plugin/google/mocks/mock_drive.go | 186 ++++++++++++ .../google/mocks/mock_drive_activity.go | 51 ++++ server/plugin/google/mocks/mock_google.go | 154 ++++++++++ server/plugin/kvstore/mocks/mock_kvstore.go | 266 ++++++++++++++++++ server/plugin/notifications.go | 12 +- server/plugin/plugin.go | 2 +- server/plugin/pluginapi/cluster.go | 33 +++ server/plugin/pluginapi/mocks/mock_cluster.go | 50 ++++ .../pluginapi/mocks/mock_cluster_mutex.go | 61 ++++ server/plugin/pluginapi/mutex_mock.go | 41 +++ server/plugin/test_utils.go | 56 ++++ 17 files changed, 1153 insertions(+), 23 deletions(-) create mode 100644 server/plugin/api_test.go create mode 100644 server/plugin/google/interfaces.go create mode 100644 server/plugin/google/mocks/mock_drive.go create mode 100644 server/plugin/google/mocks/mock_drive_activity.go create mode 100644 server/plugin/google/mocks/mock_google.go create mode 100644 server/plugin/kvstore/mocks/mock_kvstore.go create mode 100644 server/plugin/pluginapi/cluster.go create mode 100644 server/plugin/pluginapi/mocks/mock_cluster.go create mode 100644 server/plugin/pluginapi/mocks/mock_cluster_mutex.go create mode 100644 server/plugin/pluginapi/mutex_mock.go create mode 100644 server/plugin/test_utils.go diff --git a/go.mod b/go.mod index d3eb41e..b9e1484 100644 --- a/go.mod +++ b/go.mod @@ -3,15 +3,24 @@ module github.com/mattermost-community/mattermost-plugin-google-drive go 1.21 require ( + github.com/golang/mock v1.6.0 github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.1 github.com/mattermost/mattermost/server/public v0.1.4 github.com/pkg/errors v0.9.1 + github.com/stretchr/testify v1.9.0 golang.org/x/oauth2 v0.21.0 golang.org/x/text v0.16.0 google.golang.org/api v0.184.0 ) +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + require ( cloud.google.com/go/auth v0.5.1 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect @@ -52,7 +61,6 @@ require ( github.com/rudderlabs/analytics-go v3.3.3+incompatible // indirect github.com/segmentio/backo-go v1.1.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect - github.com/stretchr/objx v0.5.2 // indirect github.com/tidwall/gjson v1.17.1 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect diff --git a/go.sum b/go.sum index a838f13..3eaf449 100644 --- a/go.sum +++ b/go.sum @@ -256,6 +256,7 @@ github.com/wiggin77/srslog v1.0.1 h1:gA2XjSMy3DrRdX9UqLuDtuVAAshb8bE1NhX1YK0Qe+8 github.com/wiggin77/srslog v1.0.1/go.mod h1:fehkyYDq1QfuYn60TDPu9YdY2bB85VUW2mvN1WynEls= github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c h1:3lbZUMbMiGUW/LMkfsEABsc5zNT9+b1CvsJx47JzJ8g= github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c/go.mod h1:UrdRz5enIKZ63MEE3IF9l2/ebyx59GyGgPi+tICQdmM= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= go.opencensus.io v0.18.0/go.mod h1:vKdFvxhtzZ9onBp9VKHK8z/sRpBMnKAsufL7wlDrCOA= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= @@ -272,6 +273,7 @@ golang.org/x/build v0.0.0-20190111050920-041ab4dc3f9d/go.mod h1:OWs+y06UdEOHN4y+ golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= @@ -280,6 +282,7 @@ golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTk golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -290,7 +293,9 @@ golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190313220215-9f648a60d977/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= @@ -306,6 +311,7 @@ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -317,6 +323,9 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -327,6 +336,7 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -345,7 +355,12 @@ golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= google.golang.org/api v0.0.0-20181030000543-1d582fd0359e/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= google.golang.org/api v0.1.0/go.mod h1:UGEZY7KEX120AnNLIHFMKIo4obdJhkp2tPbaPlQx13Y= diff --git a/server/plugin/api.go b/server/plugin/api.go index 49bee3d..76870fe 100644 --- a/server/plugin/api.go +++ b/server/plugin/api.go @@ -13,7 +13,6 @@ import ( "github.com/gorilla/mux" mattermostModel "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/plugin" - "github.com/mattermost/mattermost/server/public/pluginapi/cluster" "github.com/mattermost/mattermost/server/public/pluginapi/experimental/bot/logger" "github.com/mattermost/mattermost/server/public/pluginapi/experimental/flow" "github.com/pkg/errors" @@ -24,6 +23,7 @@ import ( "google.golang.org/api/sheets/v4" "google.golang.org/api/slides/v1" + "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/pluginapi" "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/utils" ) @@ -465,13 +465,13 @@ func (p *Plugin) handleDriveWatchNotifications(c *Context, w http.ResponseWriter watchChannelData, err := p.KVStore.GetWatchChannelData(userID) if err != nil { - p.API.LogError("Unable to fund watch channel data", "err", err, "userID", userID) + p.API.LogError("Unable to find watch channel data", "err", err, "userID", userID) w.WriteHeader(http.StatusInternalServerError) return } token := r.Header.Get("X-Goog-Channel-Token") - if watchChannelData.Token != token { + if watchChannelData.Token == "" || watchChannelData.Token != token { p.API.LogError("Invalid channel token", "userID", userID) w.WriteHeader(http.StatusBadRequest) return @@ -484,8 +484,9 @@ func (p *Plugin) handleDriveWatchNotifications(c *Context, w http.ResponseWriter return } + clusterService := pluginapi.NewClusterService(p.API) // Mutex to prevent race conditions from multiple requests directed at the same user in a short period of time. - m, err := cluster.NewMutex(p.API, "drive_watch_notifications_"+userID) + m, err := clusterService.NewMutex("drive_watch_notifications_" + userID) if err != nil { p.API.LogError("Failed to create mutex", "err", err, "userID", userID) w.WriteHeader(http.StatusInternalServerError) diff --git a/server/plugin/api_test.go b/server/plugin/api_test.go new file mode 100644 index 0000000..655e1b8 --- /dev/null +++ b/server/plugin/api_test.go @@ -0,0 +1,165 @@ +package plugin + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "google.golang.org/api/drive/v3" + "google.golang.org/api/driveactivity/v2" + + "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/model" + "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/pluginapi" +) + +func TestServeHTTP(t *testing.T) { + t.Run("No UserId provided", func(t *testing.T) { + assert := assert.New(t) + + mockKvStore, _, _, _, _, _ := GetMockSetup(t) + te := SetupTestEnvironment(t) + defer te.Cleanup(t) + + te.plugin.KVStore = mockKvStore + te.plugin.initializeAPI() + + watchChannelData := &model.WatchChannelData{ + ChannelID: "", + ResourceID: "", + MMUserID: "", + Expiration: 0, + Token: "", + PageToken: "", + } + + mockKvStore.EXPECT().GetWatchChannelData("").Return(watchChannelData, nil) + te.mockAPI.On("LogError", mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string")).Maybe() + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/webhook", nil) + r.Header.Set("X-Goog-Resource-State", "change") + r.Header.Set("X-Goog-Channel-Token", "token") + te.plugin.handleDriveWatchNotifications(nil, w, r) + + result := w.Result() + require.NotNil(t, result) + defer result.Body.Close() + + assert.Equal(http.StatusBadRequest, result.StatusCode) + }) + t.Run("Invalid Google token", func(t *testing.T) { + assert := assert.New(t) + + mockKvStore, mockGoogleClient, _, _, _, _ := GetMockSetup(t) + te := SetupTestEnvironment(t) + defer te.Cleanup(t) + + te.plugin.KVStore = mockKvStore + te.plugin.GoogleClient = mockGoogleClient + te.plugin.initializeAPI() + + watchChannelData := &model.WatchChannelData{ + ChannelID: "channelId1", + ResourceID: "resourceId1", + MMUserID: "userId1", + Expiration: 0, + Token: "token1", + PageToken: "pageToken1", + } + mockKvStore.EXPECT().GetWatchChannelData("userId1").Return(watchChannelData, nil) + te.mockAPI.On("LogError", mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string")).Maybe() + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/webhook?userID=userId1", nil) + r.Header.Set("X-Goog-Resource-State", "change") + r.Header.Set("X-Goog-Channel-Token", "token") + te.plugin.handleDriveWatchNotifications(nil, w, r) + + result := w.Result() + require.NotNil(t, result) + defer result.Body.Close() + + assert.Equal(http.StatusBadRequest, result.StatusCode) + }) + + t.Run("Happy path", func(t *testing.T) { + assert := assert.New(t) + + mockKvStore, mockGoogleClient, mockGoogleDrive, mockDriveActivity, _, mockCluster := GetMockSetup(t) + te := SetupTestEnvironment(t) + defer te.Cleanup(t) + + te.plugin.KVStore = mockKvStore + te.plugin.GoogleClient = mockGoogleClient + te.plugin.initializeAPI() + + watchChannelData := &model.WatchChannelData{ + ChannelID: "channelId1", + ResourceID: "resourceId1", + MMUserID: "userId1", + Expiration: 0, + Token: "token1", + PageToken: "pageToken1", + } + mockKvStore.EXPECT().GetWatchChannelData("userId1").Return(watchChannelData, nil).MaxTimes(2) + mockGoogleClient.EXPECT().NewDriveService(context.Background(), "userId1").Return(mockGoogleDrive, nil) + mockGoogleClient.EXPECT().NewDriveActivityService(context.Background(), "userId1").Return(mockDriveActivity, nil) + te.mockAPI.On("LogError", mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string")).Maybe() + mockCluster.EXPECT().NewMutex("drive_watch_notifications_userId1").Return(pluginapi.NewClusterMutexMock(), nil) + te.mockAPI.On("KVSetWithOptions", "mutex_drive_watch_notifications_userId1", mock.Anything, mock.Anything).Return(true, nil) + mockGoogleDrive.EXPECT().ChangesList(context.Background(), "pageToken1").Return(&drive.ChangeList{ + Changes: []*drive.Change{ + { + FileId: "fileId1", + Kind: "drive#change", + File: &drive.File{ + Id: "fileId1", + ViewedByMeTime: "2020-01-01T00:00:00.000Z", + ModifiedTime: "2021-01-01T00:00:00.000Z", + }, + DriveId: "driveId1", + Removed: false, + Time: "2021-01-01T00:00:00.000Z", + }, + }, + NewStartPageToken: "newPageToken2", + NextPageToken: "", + }, nil) + mockKvStore.EXPECT().GetLastActivityForFile("userId1", "fileId1").Return("2020-01-01T00:00:00.000Z", nil) + watchChannelData = &model.WatchChannelData{ + ChannelID: "channelId1", + ResourceID: "resourceId1", + MMUserID: "userId1", + Expiration: 0, + Token: "token1", + PageToken: "newPageToken2", + } + mockKvStore.EXPECT().StoreWatchChannelData("userId1", *watchChannelData).Return(nil) + mockDriveActivity.EXPECT().Query(context.Background(), &driveactivity.QueryDriveActivityRequest{ + ItemName: fmt.Sprintf("items/%s", "fileId1"), + Filter: "time > \"2020-01-01T00:00:00.000Z\"", + }).Return(&driveactivity.QueryDriveActivityResponse{}, nil) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/webhook?userID=userId1", nil) + r.Header.Set("X-Goog-Resource-State", "change") + r.Header.Set("X-Goog-Channel-Token", "token1") + ctx := &Context{ + Ctx: context.Background(), + UserID: "userId1", + Log: nil, + } + te.plugin.handleDriveWatchNotifications(ctx, w, r) + + result := w.Result() + require.NotNil(t, result) + defer result.Body.Close() + + assert.Equal(http.StatusOK, result.StatusCode) + }) +} diff --git a/server/plugin/google/google.go b/server/plugin/google/google.go index f48b5f0..8abb17c 100644 --- a/server/plugin/google/google.go +++ b/server/plugin/google/google.go @@ -47,7 +47,7 @@ const ( driveActivityServiceType = "driveactivity" ) -func NewGoogleClient(oauthConfig *oauth2.Config, config *config.Configuration, kvstore kvstore.KVStore, papi plugin.API) *Client { +func NewGoogleClient(oauthConfig *oauth2.Config, config *config.Configuration, kvstore kvstore.KVStore, papi plugin.API) ClientInterface { maximumQueriesPerSecond := config.QueriesPerMinute / 60 burstSize := config.BurstSize @@ -60,8 +60,8 @@ func NewGoogleClient(oauthConfig *oauth2.Config, config *config.Configuration, k } } -func (g *Client) NewDriveService(ctx context.Context, userID string) (*DriveService, error) { - authToken, err := g.getGoogleUserToken(userID) +func (g *Client) NewDriveService(ctx context.Context, userID string) (DriveInterface, error) { + authToken, err := g.GetGoogleUserToken(userID) if err != nil { return nil, err } @@ -93,8 +93,8 @@ func (g *Client) NewDriveService(ctx context.Context, userID string) (*DriveServ }, nil } -func (g *Client) NewDriveV2Service(ctx context.Context, userID string) (*DriveServiceV2, error) { - authToken, err := g.getGoogleUserToken(userID) +func (g *Client) NewDriveV2Service(ctx context.Context, userID string) (DriveV2Interface, error) { + authToken, err := g.GetGoogleUserToken(userID) if err != nil { return nil, err } @@ -127,7 +127,7 @@ func (g *Client) NewDriveV2Service(ctx context.Context, userID string) (*DriveSe } func (g *Client) NewDocsService(ctx context.Context, userID string) (*DocsService, error) { - authToken, err := g.getGoogleUserToken(userID) + authToken, err := g.GetGoogleUserToken(userID) if err != nil { return nil, err } @@ -155,7 +155,7 @@ func (g *Client) NewDocsService(ctx context.Context, userID string) (*DocsServic } func (g *Client) NewSlidesService(ctx context.Context, userID string) (*SlidesService, error) { - authToken, err := g.getGoogleUserToken(userID) + authToken, err := g.GetGoogleUserToken(userID) if err != nil { return nil, err } @@ -183,7 +183,7 @@ func (g *Client) NewSlidesService(ctx context.Context, userID string) (*SlidesSe } func (g *Client) NewSheetsService(ctx context.Context, userID string) (*SheetsService, error) { - authToken, err := g.getGoogleUserToken(userID) + authToken, err := g.GetGoogleUserToken(userID) if err != nil { return nil, err } @@ -210,8 +210,8 @@ func (g *Client) NewSheetsService(ctx context.Context, userID string) (*SheetsSe }, nil } -func (g *Client) NewDriveActivityService(ctx context.Context, userID string) (*DriveActivityService, error) { - authToken, err := g.getGoogleUserToken(userID) +func (g *Client) NewDriveActivityService(ctx context.Context, userID string) (DriveActivityInterface, error) { + authToken, err := g.GetGoogleUserToken(userID) if err != nil { return nil, err } @@ -238,7 +238,7 @@ func (g *Client) NewDriveActivityService(ctx context.Context, userID string) (*D }, nil } -func (g *Client) getGoogleUserToken(userID string) (*oauth2.Token, error) { +func (g *Client) GetGoogleUserToken(userID string) (*oauth2.Token, error) { encryptedToken, err := g.kvstore.GetGoogleUserToken(userID) if err != nil { return nil, err diff --git a/server/plugin/google/interfaces.go b/server/plugin/google/interfaces.go new file mode 100644 index 0000000..da429ff --- /dev/null +++ b/server/plugin/google/interfaces.go @@ -0,0 +1,43 @@ +package google + +import ( + "context" + + "golang.org/x/oauth2" + driveV2 "google.golang.org/api/drive/v2" + "google.golang.org/api/drive/v3" + "google.golang.org/api/driveactivity/v2" + "google.golang.org/api/googleapi" +) + +type ClientInterface interface { + NewDriveService(ctx context.Context, userID string) (DriveInterface, error) + NewDriveV2Service(ctx context.Context, userID string) (DriveV2Interface, error) + NewDocsService(ctx context.Context, userID string) (*DocsService, error) + NewSlidesService(ctx context.Context, userID string) (*SlidesService, error) + NewSheetsService(ctx context.Context, userID string) (*SheetsService, error) + NewDriveActivityService(ctx context.Context, userID string) (DriveActivityInterface, error) + GetGoogleUserToken(userID string) (*oauth2.Token, error) + ReloadRateLimits(newQueriesPerMinute int, newBurstSize int) +} + +type DriveInterface interface { + About(ctx context.Context, fields googleapi.Field) (*drive.About, error) + WatchChannel(ctx context.Context, startPageToken *drive.StartPageToken, requestChannel *drive.Channel) (*drive.Channel, error) + StopChannel(ctx context.Context, channel *drive.Channel) error + ChangesList(ctx context.Context, pageToken string) (*drive.ChangeList, error) + GetStartPageToken(ctx context.Context) (*drive.StartPageToken, error) + GetComments(ctx context.Context, fileID string, commentID string) (*drive.Comment, error) + CreateReply(ctx context.Context, fileID string, commentID string, reply *drive.Reply) (*drive.Reply, error) + CreateFile(ctx context.Context, file *drive.File, fileReader []byte) (*drive.File, error) + GetFile(ctx context.Context, fileID string) (*drive.File, error) + CreatePermission(ctx context.Context, fileID string, permission *drive.Permission) (*drive.Permission, error) +} + +type DriveV2Interface interface { + About(ctx context.Context, fields googleapi.Field) (*driveV2.About, error) +} + +type DriveActivityInterface interface { + Query(ctx context.Context, request *driveactivity.QueryDriveActivityRequest) (*driveactivity.QueryDriveActivityResponse, error) +} diff --git a/server/plugin/google/mocks/mock_drive.go b/server/plugin/google/mocks/mock_drive.go new file mode 100644 index 0000000..0847fd0 --- /dev/null +++ b/server/plugin/google/mocks/mock_drive.go @@ -0,0 +1,186 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/google (interfaces: DriveInterface) + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + drive "google.golang.org/api/drive/v3" + googleapi "google.golang.org/api/googleapi" +) + +// MockDriveInterface is a mock of DriveInterface interface. +type MockDriveInterface struct { + ctrl *gomock.Controller + recorder *MockDriveInterfaceMockRecorder +} + +// MockDriveInterfaceMockRecorder is the mock recorder for MockDriveInterface. +type MockDriveInterfaceMockRecorder struct { + mock *MockDriveInterface +} + +// NewMockDriveInterface creates a new mock instance. +func NewMockDriveInterface(ctrl *gomock.Controller) *MockDriveInterface { + mock := &MockDriveInterface{ctrl: ctrl} + mock.recorder = &MockDriveInterfaceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockDriveInterface) EXPECT() *MockDriveInterfaceMockRecorder { + return m.recorder +} + +// About mocks base method. +func (m *MockDriveInterface) About(arg0 context.Context, arg1 googleapi.Field) (*drive.About, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "About", arg0, arg1) + ret0, _ := ret[0].(*drive.About) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// About indicates an expected call of About. +func (mr *MockDriveInterfaceMockRecorder) About(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "About", reflect.TypeOf((*MockDriveInterface)(nil).About), arg0, arg1) +} + +// ChangesList mocks base method. +func (m *MockDriveInterface) ChangesList(arg0 context.Context, arg1 string) (*drive.ChangeList, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ChangesList", arg0, arg1) + ret0, _ := ret[0].(*drive.ChangeList) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ChangesList indicates an expected call of ChangesList. +func (mr *MockDriveInterfaceMockRecorder) ChangesList(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChangesList", reflect.TypeOf((*MockDriveInterface)(nil).ChangesList), arg0, arg1) +} + +// CreateFile mocks base method. +func (m *MockDriveInterface) CreateFile(arg0 context.Context, arg1 *drive.File, arg2 []byte) (*drive.File, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateFile", arg0, arg1, arg2) + ret0, _ := ret[0].(*drive.File) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateFile indicates an expected call of CreateFile. +func (mr *MockDriveInterfaceMockRecorder) CreateFile(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateFile", reflect.TypeOf((*MockDriveInterface)(nil).CreateFile), arg0, arg1, arg2) +} + +// CreatePermission mocks base method. +func (m *MockDriveInterface) CreatePermission(arg0 context.Context, arg1 string, arg2 *drive.Permission) (*drive.Permission, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreatePermission", arg0, arg1, arg2) + ret0, _ := ret[0].(*drive.Permission) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreatePermission indicates an expected call of CreatePermission. +func (mr *MockDriveInterfaceMockRecorder) CreatePermission(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatePermission", reflect.TypeOf((*MockDriveInterface)(nil).CreatePermission), arg0, arg1, arg2) +} + +// CreateReply mocks base method. +func (m *MockDriveInterface) CreateReply(arg0 context.Context, arg1, arg2 string, arg3 *drive.Reply) (*drive.Reply, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateReply", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(*drive.Reply) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateReply indicates an expected call of CreateReply. +func (mr *MockDriveInterfaceMockRecorder) CreateReply(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateReply", reflect.TypeOf((*MockDriveInterface)(nil).CreateReply), arg0, arg1, arg2, arg3) +} + +// GetComments mocks base method. +func (m *MockDriveInterface) GetComments(arg0 context.Context, arg1, arg2 string) (*drive.Comment, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetComments", arg0, arg1, arg2) + ret0, _ := ret[0].(*drive.Comment) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetComments indicates an expected call of GetComments. +func (mr *MockDriveInterfaceMockRecorder) GetComments(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetComments", reflect.TypeOf((*MockDriveInterface)(nil).GetComments), arg0, arg1, arg2) +} + +// GetFile mocks base method. +func (m *MockDriveInterface) GetFile(arg0 context.Context, arg1 string) (*drive.File, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetFile", arg0, arg1) + ret0, _ := ret[0].(*drive.File) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetFile indicates an expected call of GetFile. +func (mr *MockDriveInterfaceMockRecorder) GetFile(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFile", reflect.TypeOf((*MockDriveInterface)(nil).GetFile), arg0, arg1) +} + +// GetStartPageToken mocks base method. +func (m *MockDriveInterface) GetStartPageToken(arg0 context.Context) (*drive.StartPageToken, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetStartPageToken", arg0) + ret0, _ := ret[0].(*drive.StartPageToken) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetStartPageToken indicates an expected call of GetStartPageToken. +func (mr *MockDriveInterfaceMockRecorder) GetStartPageToken(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetStartPageToken", reflect.TypeOf((*MockDriveInterface)(nil).GetStartPageToken), arg0) +} + +// StopChannel mocks base method. +func (m *MockDriveInterface) StopChannel(arg0 context.Context, arg1 *drive.Channel) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "StopChannel", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// StopChannel indicates an expected call of StopChannel. +func (mr *MockDriveInterfaceMockRecorder) StopChannel(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StopChannel", reflect.TypeOf((*MockDriveInterface)(nil).StopChannel), arg0, arg1) +} + +// WatchChannel mocks base method. +func (m *MockDriveInterface) WatchChannel(arg0 context.Context, arg1 *drive.StartPageToken, arg2 *drive.Channel) (*drive.Channel, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "WatchChannel", arg0, arg1, arg2) + ret0, _ := ret[0].(*drive.Channel) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// WatchChannel indicates an expected call of WatchChannel. +func (mr *MockDriveInterfaceMockRecorder) WatchChannel(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WatchChannel", reflect.TypeOf((*MockDriveInterface)(nil).WatchChannel), arg0, arg1, arg2) +} diff --git a/server/plugin/google/mocks/mock_drive_activity.go b/server/plugin/google/mocks/mock_drive_activity.go new file mode 100644 index 0000000..048fce7 --- /dev/null +++ b/server/plugin/google/mocks/mock_drive_activity.go @@ -0,0 +1,51 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/google (interfaces: DriveActivityInterface) + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + driveactivity "google.golang.org/api/driveactivity/v2" +) + +// MockDriveActivityInterface is a mock of DriveActivityInterface interface. +type MockDriveActivityInterface struct { + ctrl *gomock.Controller + recorder *MockDriveActivityInterfaceMockRecorder +} + +// MockDriveActivityInterfaceMockRecorder is the mock recorder for MockDriveActivityInterface. +type MockDriveActivityInterfaceMockRecorder struct { + mock *MockDriveActivityInterface +} + +// NewMockDriveActivityInterface creates a new mock instance. +func NewMockDriveActivityInterface(ctrl *gomock.Controller) *MockDriveActivityInterface { + mock := &MockDriveActivityInterface{ctrl: ctrl} + mock.recorder = &MockDriveActivityInterfaceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockDriveActivityInterface) EXPECT() *MockDriveActivityInterfaceMockRecorder { + return m.recorder +} + +// Query mocks base method. +func (m *MockDriveActivityInterface) Query(arg0 context.Context, arg1 *driveactivity.QueryDriveActivityRequest) (*driveactivity.QueryDriveActivityResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Query", arg0, arg1) + ret0, _ := ret[0].(*driveactivity.QueryDriveActivityResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Query indicates an expected call of Query. +func (mr *MockDriveActivityInterfaceMockRecorder) Query(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Query", reflect.TypeOf((*MockDriveActivityInterface)(nil).Query), arg0, arg1) +} diff --git a/server/plugin/google/mocks/mock_google.go b/server/plugin/google/mocks/mock_google.go new file mode 100644 index 0000000..d47ffdb --- /dev/null +++ b/server/plugin/google/mocks/mock_google.go @@ -0,0 +1,154 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/google (interfaces: ClientInterface) + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + google "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/google" + oauth2 "golang.org/x/oauth2" +) + +// MockClientInterface is a mock of ClientInterface interface. +type MockClientInterface struct { + ctrl *gomock.Controller + recorder *MockClientInterfaceMockRecorder +} + +// MockClientInterfaceMockRecorder is the mock recorder for MockClientInterface. +type MockClientInterfaceMockRecorder struct { + mock *MockClientInterface +} + +// NewMockClientInterface creates a new mock instance. +func NewMockClientInterface(ctrl *gomock.Controller) *MockClientInterface { + mock := &MockClientInterface{ctrl: ctrl} + mock.recorder = &MockClientInterfaceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockClientInterface) EXPECT() *MockClientInterfaceMockRecorder { + return m.recorder +} + +// GetGoogleUserToken mocks base method. +func (m *MockClientInterface) GetGoogleUserToken(arg0 string) (*oauth2.Token, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetGoogleUserToken", arg0) + ret0, _ := ret[0].(*oauth2.Token) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetGoogleUserToken indicates an expected call of GetGoogleUserToken. +func (mr *MockClientInterfaceMockRecorder) GetGoogleUserToken(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGoogleUserToken", reflect.TypeOf((*MockClientInterface)(nil).GetGoogleUserToken), arg0) +} + +// NewDocsService mocks base method. +func (m *MockClientInterface) NewDocsService(arg0 context.Context, arg1 string) (*google.DocsService, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NewDocsService", arg0, arg1) + ret0, _ := ret[0].(*google.DocsService) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// NewDocsService indicates an expected call of NewDocsService. +func (mr *MockClientInterfaceMockRecorder) NewDocsService(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewDocsService", reflect.TypeOf((*MockClientInterface)(nil).NewDocsService), arg0, arg1) +} + +// NewDriveActivityService mocks base method. +func (m *MockClientInterface) NewDriveActivityService(arg0 context.Context, arg1 string) (google.DriveActivityInterface, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NewDriveActivityService", arg0, arg1) + ret0, _ := ret[0].(google.DriveActivityInterface) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// NewDriveActivityService indicates an expected call of NewDriveActivityService. +func (mr *MockClientInterfaceMockRecorder) NewDriveActivityService(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewDriveActivityService", reflect.TypeOf((*MockClientInterface)(nil).NewDriveActivityService), arg0, arg1) +} + +// NewDriveService mocks base method. +func (m *MockClientInterface) NewDriveService(arg0 context.Context, arg1 string) (google.DriveInterface, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NewDriveService", arg0, arg1) + ret0, _ := ret[0].(google.DriveInterface) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// NewDriveService indicates an expected call of NewDriveService. +func (mr *MockClientInterfaceMockRecorder) NewDriveService(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewDriveService", reflect.TypeOf((*MockClientInterface)(nil).NewDriveService), arg0, arg1) +} + +// NewDriveV2Service mocks base method. +func (m *MockClientInterface) NewDriveV2Service(arg0 context.Context, arg1 string) (google.DriveV2Interface, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NewDriveV2Service", arg0, arg1) + ret0, _ := ret[0].(google.DriveV2Interface) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// NewDriveV2Service indicates an expected call of NewDriveV2Service. +func (mr *MockClientInterfaceMockRecorder) NewDriveV2Service(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewDriveV2Service", reflect.TypeOf((*MockClientInterface)(nil).NewDriveV2Service), arg0, arg1) +} + +// NewSheetsService mocks base method. +func (m *MockClientInterface) NewSheetsService(arg0 context.Context, arg1 string) (*google.SheetsService, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NewSheetsService", arg0, arg1) + ret0, _ := ret[0].(*google.SheetsService) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// NewSheetsService indicates an expected call of NewSheetsService. +func (mr *MockClientInterfaceMockRecorder) NewSheetsService(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewSheetsService", reflect.TypeOf((*MockClientInterface)(nil).NewSheetsService), arg0, arg1) +} + +// NewSlidesService mocks base method. +func (m *MockClientInterface) NewSlidesService(arg0 context.Context, arg1 string) (*google.SlidesService, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NewSlidesService", arg0, arg1) + ret0, _ := ret[0].(*google.SlidesService) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// NewSlidesService indicates an expected call of NewSlidesService. +func (mr *MockClientInterfaceMockRecorder) NewSlidesService(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewSlidesService", reflect.TypeOf((*MockClientInterface)(nil).NewSlidesService), arg0, arg1) +} + +// ReloadRateLimits mocks base method. +func (m *MockClientInterface) ReloadRateLimits(arg0, arg1 int) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "ReloadRateLimits", arg0, arg1) +} + +// ReloadRateLimits indicates an expected call of ReloadRateLimits. +func (mr *MockClientInterfaceMockRecorder) ReloadRateLimits(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReloadRateLimits", reflect.TypeOf((*MockClientInterface)(nil).ReloadRateLimits), arg0, arg1) +} diff --git a/server/plugin/kvstore/mocks/mock_kvstore.go b/server/plugin/kvstore/mocks/mock_kvstore.go new file mode 100644 index 0000000..58097eb --- /dev/null +++ b/server/plugin/kvstore/mocks/mock_kvstore.go @@ -0,0 +1,266 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/kvstore (interfaces: KVStore) + +// Package mocks is a generated GoMock package. +package mocks + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + model "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/model" +) + +// MockKVStore is a mock of KVStore interface. +type MockKVStore struct { + ctrl *gomock.Controller + recorder *MockKVStoreMockRecorder +} + +// MockKVStoreMockRecorder is the mock recorder for MockKVStore. +type MockKVStoreMockRecorder struct { + mock *MockKVStore +} + +// NewMockKVStore creates a new mock instance. +func NewMockKVStore(ctrl *gomock.Controller) *MockKVStore { + mock := &MockKVStore{ctrl: ctrl} + mock.recorder = &MockKVStoreMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockKVStore) EXPECT() *MockKVStoreMockRecorder { + return m.recorder +} + +// DeleteGoogleUserToken mocks base method. +func (m *MockKVStore) DeleteGoogleUserToken(arg0 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteGoogleUserToken", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteGoogleUserToken indicates an expected call of DeleteGoogleUserToken. +func (mr *MockKVStoreMockRecorder) DeleteGoogleUserToken(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteGoogleUserToken", reflect.TypeOf((*MockKVStore)(nil).DeleteGoogleUserToken), arg0) +} + +// DeleteOAuthStateToken mocks base method. +func (m *MockKVStore) DeleteOAuthStateToken(arg0 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteOAuthStateToken", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteOAuthStateToken indicates an expected call of DeleteOAuthStateToken. +func (mr *MockKVStoreMockRecorder) DeleteOAuthStateToken(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOAuthStateToken", reflect.TypeOf((*MockKVStore)(nil).DeleteOAuthStateToken), arg0) +} + +// DeleteWatchChannelData mocks base method. +func (m *MockKVStore) DeleteWatchChannelData(arg0 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteWatchChannelData", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteWatchChannelData indicates an expected call of DeleteWatchChannelData. +func (mr *MockKVStoreMockRecorder) DeleteWatchChannelData(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteWatchChannelData", reflect.TypeOf((*MockKVStore)(nil).DeleteWatchChannelData), arg0) +} + +// GetGoogleUserToken mocks base method. +func (m *MockKVStore) GetGoogleUserToken(arg0 string) ([]byte, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetGoogleUserToken", arg0) + ret0, _ := ret[0].([]byte) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetGoogleUserToken indicates an expected call of GetGoogleUserToken. +func (mr *MockKVStoreMockRecorder) GetGoogleUserToken(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGoogleUserToken", reflect.TypeOf((*MockKVStore)(nil).GetGoogleUserToken), arg0) +} + +// GetLastActivityForFile mocks base method. +func (m *MockKVStore) GetLastActivityForFile(arg0, arg1 string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetLastActivityForFile", arg0, arg1) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetLastActivityForFile indicates an expected call of GetLastActivityForFile. +func (mr *MockKVStoreMockRecorder) GetLastActivityForFile(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLastActivityForFile", reflect.TypeOf((*MockKVStore)(nil).GetLastActivityForFile), arg0, arg1) +} + +// GetOAuthStateToken mocks base method. +func (m *MockKVStore) GetOAuthStateToken(arg0 string) ([]byte, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOAuthStateToken", arg0) + ret0, _ := ret[0].([]byte) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetOAuthStateToken indicates an expected call of GetOAuthStateToken. +func (mr *MockKVStoreMockRecorder) GetOAuthStateToken(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOAuthStateToken", reflect.TypeOf((*MockKVStore)(nil).GetOAuthStateToken), arg0) +} + +// GetProjectRateLimitExceeded mocks base method. +func (m *MockKVStore) GetProjectRateLimitExceeded(arg0 string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetProjectRateLimitExceeded", arg0) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetProjectRateLimitExceeded indicates an expected call of GetProjectRateLimitExceeded. +func (mr *MockKVStoreMockRecorder) GetProjectRateLimitExceeded(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProjectRateLimitExceeded", reflect.TypeOf((*MockKVStore)(nil).GetProjectRateLimitExceeded), arg0) +} + +// GetUserRateLimitExceeded mocks base method. +func (m *MockKVStore) GetUserRateLimitExceeded(arg0, arg1 string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserRateLimitExceeded", arg0, arg1) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserRateLimitExceeded indicates an expected call of GetUserRateLimitExceeded. +func (mr *MockKVStoreMockRecorder) GetUserRateLimitExceeded(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserRateLimitExceeded", reflect.TypeOf((*MockKVStore)(nil).GetUserRateLimitExceeded), arg0, arg1) +} + +// GetWatchChannelData mocks base method. +func (m *MockKVStore) GetWatchChannelData(arg0 string) (*model.WatchChannelData, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetWatchChannelData", arg0) + ret0, _ := ret[0].(*model.WatchChannelData) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetWatchChannelData indicates an expected call of GetWatchChannelData. +func (mr *MockKVStoreMockRecorder) GetWatchChannelData(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWatchChannelData", reflect.TypeOf((*MockKVStore)(nil).GetWatchChannelData), arg0) +} + +// ListWatchChannelDataKeys mocks base method. +func (m *MockKVStore) ListWatchChannelDataKeys(arg0, arg1 int) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListWatchChannelDataKeys", arg0, arg1) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListWatchChannelDataKeys indicates an expected call of ListWatchChannelDataKeys. +func (mr *MockKVStoreMockRecorder) ListWatchChannelDataKeys(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListWatchChannelDataKeys", reflect.TypeOf((*MockKVStore)(nil).ListWatchChannelDataKeys), arg0, arg1) +} + +// StoreGoogleUserToken mocks base method. +func (m *MockKVStore) StoreGoogleUserToken(arg0, arg1 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "StoreGoogleUserToken", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// StoreGoogleUserToken indicates an expected call of StoreGoogleUserToken. +func (mr *MockKVStoreMockRecorder) StoreGoogleUserToken(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StoreGoogleUserToken", reflect.TypeOf((*MockKVStore)(nil).StoreGoogleUserToken), arg0, arg1) +} + +// StoreLastActivityForFile mocks base method. +func (m *MockKVStore) StoreLastActivityForFile(arg0, arg1, arg2 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "StoreLastActivityForFile", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// StoreLastActivityForFile indicates an expected call of StoreLastActivityForFile. +func (mr *MockKVStoreMockRecorder) StoreLastActivityForFile(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StoreLastActivityForFile", reflect.TypeOf((*MockKVStore)(nil).StoreLastActivityForFile), arg0, arg1, arg2) +} + +// StoreOAuthStateToken mocks base method. +func (m *MockKVStore) StoreOAuthStateToken(arg0, arg1 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "StoreOAuthStateToken", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// StoreOAuthStateToken indicates an expected call of StoreOAuthStateToken. +func (mr *MockKVStoreMockRecorder) StoreOAuthStateToken(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StoreOAuthStateToken", reflect.TypeOf((*MockKVStore)(nil).StoreOAuthStateToken), arg0, arg1) +} + +// StoreProjectRateLimitExceeded mocks base method. +func (m *MockKVStore) StoreProjectRateLimitExceeded(arg0 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "StoreProjectRateLimitExceeded", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// StoreProjectRateLimitExceeded indicates an expected call of StoreProjectRateLimitExceeded. +func (mr *MockKVStoreMockRecorder) StoreProjectRateLimitExceeded(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StoreProjectRateLimitExceeded", reflect.TypeOf((*MockKVStore)(nil).StoreProjectRateLimitExceeded), arg0) +} + +// StoreUserRateLimitExceeded mocks base method. +func (m *MockKVStore) StoreUserRateLimitExceeded(arg0, arg1 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "StoreUserRateLimitExceeded", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// StoreUserRateLimitExceeded indicates an expected call of StoreUserRateLimitExceeded. +func (mr *MockKVStoreMockRecorder) StoreUserRateLimitExceeded(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StoreUserRateLimitExceeded", reflect.TypeOf((*MockKVStore)(nil).StoreUserRateLimitExceeded), arg0, arg1) +} + +// StoreWatchChannelData mocks base method. +func (m *MockKVStore) StoreWatchChannelData(arg0 string, arg1 model.WatchChannelData) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "StoreWatchChannelData", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// StoreWatchChannelData indicates an expected call of StoreWatchChannelData. +func (mr *MockKVStoreMockRecorder) StoreWatchChannelData(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StoreWatchChannelData", reflect.TypeOf((*MockKVStore)(nil).StoreWatchChannelData), arg0, arg1) +} diff --git a/server/plugin/notifications.go b/server/plugin/notifications.go index 6f90ba6..6c9ce21 100644 --- a/server/plugin/notifications.go +++ b/server/plugin/notifications.go @@ -19,7 +19,7 @@ import ( "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/utils" ) -func getCommentUsingDiscussionID(ctx context.Context, dSrv *google.DriveService, fileID string, activity *driveactivity.DriveActivity) (*drive.Comment, error) { +func getCommentUsingDiscussionID(ctx context.Context, dSrv google.DriveInterface, fileID string, activity *driveactivity.DriveActivity) (*drive.Comment, error) { if len(activity.Targets) == 0 || activity.Targets[0].FileComment == nil || activity.Targets[0].FileComment.LegacyDiscussionId == "" { @@ -33,7 +33,7 @@ func getCommentUsingDiscussionID(ctx context.Context, dSrv *google.DriveService, return comment, nil } -func (p *Plugin) handleAddedComment(ctx context.Context, dSrv *google.DriveService, fileID, userID string, activity *driveactivity.DriveActivity, file *drive.File) { +func (p *Plugin) handleAddedComment(ctx context.Context, dSrv google.DriveInterface, fileID, userID string, activity *driveactivity.DriveActivity, file *drive.File) { comment, err := getCommentUsingDiscussionID(ctx, dSrv, fileID, activity) if err != nil { p.API.LogError("Failed to get comment by legacyDiscussionId", "err", err, "userID", userID) @@ -72,7 +72,7 @@ func (p *Plugin) handleDeletedComment(userID string, activity *driveactivity.Dri p.createBotDMPost(userID, message, nil) } -func (p *Plugin) handleReplyAdded(ctx context.Context, dSrv *google.DriveService, fileID, userID string, activity *driveactivity.DriveActivity, file *drive.File) { +func (p *Plugin) handleReplyAdded(ctx context.Context, dSrv google.DriveInterface, fileID, userID string, activity *driveactivity.DriveActivity, file *drive.File) { comment, err := getCommentUsingDiscussionID(ctx, dSrv, fileID, activity) if err != nil { p.API.LogError("Failed to get comment by legacyDiscussionId", "err", err, "userID", userID) @@ -120,7 +120,7 @@ func (p *Plugin) handleReplyDeleted(userID string, activity *driveactivity.Drive p.createBotDMPost(userID, message, nil) } -func (p *Plugin) handleResolvedComment(ctx context.Context, dSrv *google.DriveService, fileID, userID string, activity *driveactivity.DriveActivity, file *drive.File) { +func (p *Plugin) handleResolvedComment(ctx context.Context, dSrv google.DriveInterface, fileID, userID string, activity *driveactivity.DriveActivity, file *drive.File) { if len(activity.Targets) == 0 || activity.Targets[0].FileComment == nil || activity.Targets[0].FileComment.LegacyCommentId == "" { @@ -138,7 +138,7 @@ func (p *Plugin) handleResolvedComment(ctx context.Context, dSrv *google.DriveSe p.createBotDMPost(userID, message, nil) } -func (p *Plugin) handleReopenedComment(ctx context.Context, dSrv *google.DriveService, fileID, userID string, activity *driveactivity.DriveActivity, file *drive.File) { +func (p *Plugin) handleReopenedComment(ctx context.Context, dSrv google.DriveInterface, fileID, userID string, activity *driveactivity.DriveActivity, file *drive.File) { comment, err := getCommentUsingDiscussionID(ctx, dSrv, fileID, activity) if err != nil { p.API.LogError("Failed to get comment by legacyDiscussionId", "err", err, "userID", userID) @@ -155,7 +155,7 @@ func (p *Plugin) handleSuggestionReplyAdded(userID string, activity *driveactivi p.createBotDMPost(userID, message, nil) } -func (p *Plugin) handleCommentNotifications(ctx context.Context, dSrv *google.DriveService, file *drive.File, userID string, activity *driveactivity.DriveActivity) { +func (p *Plugin) handleCommentNotifications(ctx context.Context, dSrv google.DriveInterface, file *drive.File, userID string, activity *driveactivity.DriveActivity) { fileID := file.Id if ok := activity.PrimaryActionDetail.Comment.Post != nil; !ok { diff --git a/server/plugin/plugin.go b/server/plugin/plugin.go index 88a05d7..28b726a 100644 --- a/server/plugin/plugin.go +++ b/server/plugin/plugin.go @@ -52,7 +52,7 @@ type Plugin struct { channelRefreshJob *cluster.Job - GoogleClient *google.Client + GoogleClient google.ClientInterface } func (p *Plugin) ensurePluginAPIClient() { diff --git a/server/plugin/pluginapi/cluster.go b/server/plugin/pluginapi/cluster.go new file mode 100644 index 0000000..f61db1c --- /dev/null +++ b/server/plugin/pluginapi/cluster.go @@ -0,0 +1,33 @@ +package pluginapi + +import ( + "context" + + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/pluginapi/cluster" +) + +type Cluster interface { + NewMutex(key string) (ClusterMutex, error) +} + +type ClusterMutex interface { + LockWithContext(ctx context.Context) error + Unlock() +} + +// ClusterService exposes methods from the mm server cluster package. +type ClusterService struct { + api plugin.API +} + +func NewClusterService(api plugin.API) *ClusterService { + return &ClusterService{ + api: api, + } +} + +// NewMutex creates a mutex with the given key name. +func (c *ClusterService) NewMutex(key string) (ClusterMutex, error) { + return cluster.NewMutex(c.api, key) +} diff --git a/server/plugin/pluginapi/mocks/mock_cluster.go b/server/plugin/pluginapi/mocks/mock_cluster.go new file mode 100644 index 0000000..0be5d45 --- /dev/null +++ b/server/plugin/pluginapi/mocks/mock_cluster.go @@ -0,0 +1,50 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/pluginapi (interfaces: Cluster) + +// Package mocks is a generated GoMock package. +package mocks + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + pluginapi "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/pluginapi" +) + +// MockCluster is a mock of Cluster interface. +type MockCluster struct { + ctrl *gomock.Controller + recorder *MockClusterMockRecorder +} + +// MockClusterMockRecorder is the mock recorder for MockCluster. +type MockClusterMockRecorder struct { + mock *MockCluster +} + +// NewMockCluster creates a new mock instance. +func NewMockCluster(ctrl *gomock.Controller) *MockCluster { + mock := &MockCluster{ctrl: ctrl} + mock.recorder = &MockClusterMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockCluster) EXPECT() *MockClusterMockRecorder { + return m.recorder +} + +// NewMutex mocks base method. +func (m *MockCluster) NewMutex(arg0 string) (pluginapi.ClusterMutex, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NewMutex", arg0) + ret0, _ := ret[0].(pluginapi.ClusterMutex) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// NewMutex indicates an expected call of NewMutex. +func (mr *MockClusterMockRecorder) NewMutex(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewMutex", reflect.TypeOf((*MockCluster)(nil).NewMutex), arg0) +} diff --git a/server/plugin/pluginapi/mocks/mock_cluster_mutex.go b/server/plugin/pluginapi/mocks/mock_cluster_mutex.go new file mode 100644 index 0000000..f031f3c --- /dev/null +++ b/server/plugin/pluginapi/mocks/mock_cluster_mutex.go @@ -0,0 +1,61 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/pluginapi (interfaces: ClusterMutex) + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" +) + +// MockClusterMutex is a mock of ClusterMutex interface. +type MockClusterMutex struct { + ctrl *gomock.Controller + recorder *MockClusterMutexMockRecorder +} + +// MockClusterMutexMockRecorder is the mock recorder for MockClusterMutex. +type MockClusterMutexMockRecorder struct { + mock *MockClusterMutex +} + +// NewMockClusterMutex creates a new mock instance. +func NewMockClusterMutex(ctrl *gomock.Controller) *MockClusterMutex { + mock := &MockClusterMutex{ctrl: ctrl} + mock.recorder = &MockClusterMutexMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockClusterMutex) EXPECT() *MockClusterMutexMockRecorder { + return m.recorder +} + +// LockWithContext mocks base method. +func (m *MockClusterMutex) LockWithContext(arg0 context.Context) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LockWithContext", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// LockWithContext indicates an expected call of LockWithContext. +func (mr *MockClusterMutexMockRecorder) LockWithContext(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LockWithContext", reflect.TypeOf((*MockClusterMutex)(nil).LockWithContext), arg0) +} + +// Unlock mocks base method. +func (m *MockClusterMutex) Unlock() { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Unlock") +} + +// Unlock indicates an expected call of Unlock. +func (mr *MockClusterMutexMockRecorder) Unlock() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Unlock", reflect.TypeOf((*MockClusterMutex)(nil).Unlock)) +} diff --git a/server/plugin/pluginapi/mutex_mock.go b/server/plugin/pluginapi/mutex_mock.go new file mode 100644 index 0000000..739d9b4 --- /dev/null +++ b/server/plugin/pluginapi/mutex_mock.go @@ -0,0 +1,41 @@ +package pluginapi + +import ( + "context" + "errors" + "sync/atomic" + "time" +) + +var ( + ErrLockTimeout = errors.New("timeout") +) + +type ClusterMutexMock struct { + locked int32 +} + +func NewClusterMutexMock() *ClusterMutexMock { + return &ClusterMutexMock{} +} + +func (m *ClusterMutexMock) LockWithContext(ctx context.Context) error { + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + if atomic.CompareAndSwapInt32(&m.locked, 0, 1) { + // we have the lock + return nil + } + } + time.Sleep(time.Millisecond * 20) + } +} + +func (m *ClusterMutexMock) Unlock() { + if !atomic.CompareAndSwapInt32(&m.locked, 1, 0) { + panic("not locked") + } +} diff --git a/server/plugin/test_utils.go b/server/plugin/test_utils.go new file mode 100644 index 0000000..a9fc7e5 --- /dev/null +++ b/server/plugin/test_utils.go @@ -0,0 +1,56 @@ +package plugin + +import ( + "testing" + + "github.com/golang/mock/gomock" + mock_pluginapi "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/pluginapi/mocks" + "github.com/mattermost/mattermost/server/public/plugin/plugintest" + "github.com/mattermost/mattermost/server/public/pluginapi" + + mock_google "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/google/mocks" + + mock_store "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/kvstore/mocks" +) + +type TestEnvironment struct { + plugin *Plugin + mockAPI *plugintest.API +} + +func SetupTestEnvironment(t *testing.T) *TestEnvironment { + p := Plugin{} + + e := &TestEnvironment{ + plugin: &p, + } + e.ResetMocks(t) + + return e +} + +func (e *TestEnvironment) Cleanup(t *testing.T) { + t.Helper() + e.mockAPI.AssertExpectations(t) +} + +func (e *TestEnvironment) ResetMocks(t *testing.T) { + e.mockAPI = &plugintest.API{} + e.plugin.SetAPI(e.mockAPI) + e.plugin.Client = pluginapi.NewClient(e.plugin.API, e.plugin.Driver) +} + +// revive:disable-next-line:unexported-return +func GetMockSetup(t *testing.T) (*mock_store.MockKVStore, *mock_google.MockClientInterface, *mock_google.MockDriveInterface, *mock_google.MockDriveActivityInterface, *mock_pluginapi.MockClusterMutex, *mock_pluginapi.MockCluster) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockKvStore := mock_store.NewMockKVStore(ctrl) + mockGoogleClient := mock_google.NewMockClientInterface(ctrl) + mockGoogleDrive := mock_google.NewMockDriveInterface(ctrl) + mockDriveActivity := mock_google.NewMockDriveActivityInterface(ctrl) + mockClusterMutex := mock_pluginapi.NewMockClusterMutex(ctrl) + mockCluster := mock_pluginapi.NewMockCluster(ctrl) + + return mockKvStore, mockGoogleClient, mockGoogleDrive, mockDriveActivity, mockClusterMutex, mockCluster +} From ea83766de17e532c7f715fb66f1a357f227342e1 Mon Sep 17 00:00:00 2001 From: Benjamin Cooke Date: Fri, 1 Nov 2024 15:57:07 -0400 Subject: [PATCH 12/17] add tests for file creation --- server/plugin/api.go | 5 + server/plugin/api_test.go | 765 ++++++++++++++++++---- server/plugin/create.go | 5 +- server/plugin/google/google.go | 6 +- server/plugin/google/interfaces.go | 21 +- server/plugin/google/mocks/mock_docs.go | 51 ++ server/plugin/google/mocks/mock_google.go | 12 +- server/plugin/google/mocks/mock_sheets.go | 51 ++ server/plugin/google/mocks/mock_slides.go | 51 ++ server/plugin/plugin_utils.go | 4 +- server/plugin/test_utils.go | 154 ++++- 11 files changed, 965 insertions(+), 160 deletions(-) create mode 100644 server/plugin/google/mocks/mock_docs.go create mode 100644 server/plugin/google/mocks/mock_sheets.go create mode 100644 server/plugin/google/mocks/mock_slides.go diff --git a/server/plugin/api.go b/server/plugin/api.go index 76870fe..9eb777e 100644 --- a/server/plugin/api.go +++ b/server/plugin/api.go @@ -378,6 +378,11 @@ func (p *Plugin) handleFileCreation(c *Context, w http.ResponseWriter, r *http.R var fileCreationErr error createdFileID := "" fileType := r.URL.Query().Get("type") + if fileType == "" { + c.Log.Errorf("File type not found in the request") + p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusBadRequest}) + return + } switch fileType { case "doc": { diff --git a/server/plugin/api_test.go b/server/plugin/api_test.go index 655e1b8..69db209 100644 --- a/server/plugin/api_test.go +++ b/server/plugin/api_test.go @@ -1,165 +1,646 @@ package plugin import ( + "bytes" "context" + "encoding/json" "fmt" "net/http" "net/http/httptest" "testing" + "time" + mattermostModel "github.com/mattermost/mattermost/server/public/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "google.golang.org/api/docs/v1" "google.golang.org/api/drive/v3" "google.golang.org/api/driveactivity/v2" + "google.golang.org/api/sheets/v4" + "google.golang.org/api/slides/v1" "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/model" "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/pluginapi" + "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/utils" ) -func TestServeHTTP(t *testing.T) { - t.Run("No UserId provided", func(t *testing.T) { - assert := assert.New(t) - - mockKvStore, _, _, _, _, _ := GetMockSetup(t) - te := SetupTestEnvironment(t) - defer te.Cleanup(t) - - te.plugin.KVStore = mockKvStore - te.plugin.initializeAPI() - - watchChannelData := &model.WatchChannelData{ - ChannelID: "", - ResourceID: "", - MMUserID: "", - Expiration: 0, - Token: "", - PageToken: "", - } - - mockKvStore.EXPECT().GetWatchChannelData("").Return(watchChannelData, nil) - te.mockAPI.On("LogError", mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string")).Maybe() - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodPost, "/webhook", nil) - r.Header.Set("X-Goog-Resource-State", "change") - r.Header.Set("X-Goog-Channel-Token", "token") - te.plugin.handleDriveWatchNotifications(nil, w, r) - - result := w.Result() - require.NotNil(t, result) - defer result.Body.Close() - - assert.Equal(http.StatusBadRequest, result.StatusCode) - }) - t.Run("Invalid Google token", func(t *testing.T) { - assert := assert.New(t) - - mockKvStore, mockGoogleClient, _, _, _, _ := GetMockSetup(t) - te := SetupTestEnvironment(t) - defer te.Cleanup(t) - - te.plugin.KVStore = mockKvStore - te.plugin.GoogleClient = mockGoogleClient - te.plugin.initializeAPI() - - watchChannelData := &model.WatchChannelData{ - ChannelID: "channelId1", - ResourceID: "resourceId1", - MMUserID: "userId1", - Expiration: 0, - Token: "token1", - PageToken: "pageToken1", - } - mockKvStore.EXPECT().GetWatchChannelData("userId1").Return(watchChannelData, nil) - te.mockAPI.On("LogError", mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string")).Maybe() - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodPost, "/webhook?userID=userId1", nil) - r.Header.Set("X-Goog-Resource-State", "change") - r.Header.Set("X-Goog-Channel-Token", "token") - te.plugin.handleDriveWatchNotifications(nil, w, r) - - result := w.Result() - require.NotNil(t, result) - defer result.Body.Close() - - assert.Equal(http.StatusBadRequest, result.StatusCode) - }) - - t.Run("Happy path", func(t *testing.T) { - assert := assert.New(t) - - mockKvStore, mockGoogleClient, mockGoogleDrive, mockDriveActivity, _, mockCluster := GetMockSetup(t) - te := SetupTestEnvironment(t) - defer te.Cleanup(t) - - te.plugin.KVStore = mockKvStore - te.plugin.GoogleClient = mockGoogleClient - te.plugin.initializeAPI() - - watchChannelData := &model.WatchChannelData{ - ChannelID: "channelId1", - ResourceID: "resourceId1", - MMUserID: "userId1", - Expiration: 0, - Token: "token1", - PageToken: "pageToken1", - } - mockKvStore.EXPECT().GetWatchChannelData("userId1").Return(watchChannelData, nil).MaxTimes(2) - mockGoogleClient.EXPECT().NewDriveService(context.Background(), "userId1").Return(mockGoogleDrive, nil) - mockGoogleClient.EXPECT().NewDriveActivityService(context.Background(), "userId1").Return(mockDriveActivity, nil) - te.mockAPI.On("LogError", mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string")).Maybe() - mockCluster.EXPECT().NewMutex("drive_watch_notifications_userId1").Return(pluginapi.NewClusterMutexMock(), nil) - te.mockAPI.On("KVSetWithOptions", "mutex_drive_watch_notifications_userId1", mock.Anything, mock.Anything).Return(true, nil) - mockGoogleDrive.EXPECT().ChangesList(context.Background(), "pageToken1").Return(&drive.ChangeList{ - Changes: []*drive.Change{ - { - FileId: "fileId1", - Kind: "drive#change", - File: &drive.File{ - Id: "fileId1", - ViewedByMeTime: "2020-01-01T00:00:00.000Z", - ModifiedTime: "2021-01-01T00:00:00.000Z", +func TestNotificationWebhook(t *testing.T) { + mockKvStore, mockGoogleClient, mockGoogleDrive, mockDriveActivity, _, _, _, _, mockCluster := GetMockSetup(t) + + for name, test := range map[string]struct { + expectedStatusCode int + envSetup func(e *TestEnvironment) + modifyRequest func(*http.Request) *http.Request + }{ + "No UserId provided": { + expectedStatusCode: http.StatusBadRequest, + envSetup: func(te *TestEnvironment) { + watchChannelData := &model.WatchChannelData{ + ChannelID: "", + ResourceID: "", + MMUserID: "", + Expiration: 0, + Token: "", + PageToken: "", + } + mockKvStore.EXPECT().GetWatchChannelData("").Return(watchChannelData, nil) + te.mockAPI.On("LogError", mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string")).Maybe() + }, + modifyRequest: func(r *http.Request) *http.Request { + r.URL.RawQuery = "" + return r + }, + }, + "Invalid Google token": { + expectedStatusCode: http.StatusBadRequest, + envSetup: func(te *TestEnvironment) { + watchChannelData := GetSampleWatchChannelData() + mockKvStore.EXPECT().GetWatchChannelData("userId1").Return(watchChannelData, nil) + te.mockAPI.On("LogError", mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string")).Maybe() + }, + modifyRequest: func(r *http.Request) *http.Request { + r.Header.Set("X-Goog-Channel-Token", "token") + return r + }, + }, + "Page token missing from KVstore but retrieve from GetStartPageToken method": { + expectedStatusCode: http.StatusOK, + envSetup: func(te *TestEnvironment) { + watchChannelData := &model.WatchChannelData{ + ChannelID: "channelId1", + ResourceID: "resourceId1", + MMUserID: "userId1", + Expiration: 0, + Token: "token1", + PageToken: "", + } + mockKvStore.EXPECT().GetWatchChannelData("userId1").Return(watchChannelData, nil).MaxTimes(2) + mockGoogleClient.EXPECT().NewDriveService(context.Background(), "userId1").Return(mockGoogleDrive, nil) + mockCluster.EXPECT().NewMutex("drive_watch_notifications_userId1").Return(pluginapi.NewClusterMutexMock(), nil) + te.mockAPI.On("KVSetWithOptions", "mutex_drive_watch_notifications_userId1", mock.Anything, mock.Anything).Return(true, nil) + mockGoogleDrive.EXPECT().GetStartPageToken(context.Background()).Return(&drive.StartPageToken{ + StartPageToken: "newPageToken1", + }, nil) + mockGoogleDrive.EXPECT().ChangesList(context.Background(), "newPageToken1").Return(&drive.ChangeList{NewStartPageToken: "newPageToken2"}, nil) + newWatchChannelData := &model.WatchChannelData{ + ChannelID: "channelId1", + ResourceID: "resourceId1", + MMUserID: "userId1", + Expiration: 0, + Token: "token1", + PageToken: "newPageToken2", + } + mockKvStore.EXPECT().StoreWatchChannelData("userId1", *newWatchChannelData).Return(nil) + }, + }, + "Ensure we only hit the changelist a maximum of 5 times": { + expectedStatusCode: http.StatusOK, + envSetup: func(te *TestEnvironment) { + watchChannelData := GetSampleWatchChannelData() + mockKvStore.EXPECT().GetWatchChannelData("userId1").Return(watchChannelData, nil).MaxTimes(2) + mockGoogleClient.EXPECT().NewDriveService(context.Background(), "userId1").Return(mockGoogleDrive, nil) + mockCluster.EXPECT().NewMutex("drive_watch_notifications_userId1").Return(pluginapi.NewClusterMutexMock(), nil) + te.mockAPI.On("KVSetWithOptions", "mutex_drive_watch_notifications_userId1", mock.Anything, mock.Anything).Return(true, nil) + mockGoogleDrive.EXPECT().ChangesList(context.Background(), "pageToken1").Return(&drive.ChangeList{NewStartPageToken: "", NextPageToken: "pageToken1", Changes: []*drive.Change{}}, nil).MaxTimes(5) + mockKvStore.EXPECT().StoreWatchChannelData("userId1", *watchChannelData).Return(nil) + }, + }, + "Ensure we don't send the user a notification if they have opened the file since the last change": { + expectedStatusCode: http.StatusOK, + envSetup: func(te *TestEnvironment) { + watchChannelData := GetSampleWatchChannelData() + mockKvStore.EXPECT().GetWatchChannelData("userId1").Return(watchChannelData, nil).MaxTimes(2) + mockGoogleClient.EXPECT().NewDriveService(context.Background(), "userId1").Return(mockGoogleDrive, nil) + mockCluster.EXPECT().NewMutex("drive_watch_notifications_userId1").Return(pluginapi.NewClusterMutexMock(), nil) + te.mockAPI.On("KVSetWithOptions", "mutex_drive_watch_notifications_userId1", mock.Anything, mock.Anything).Return(true, nil) + changeList := GetSampleChangeList() + changeList.Changes[0].File.ViewedByMeTime = "2021-01-02T00:00:00.000Z" + mockGoogleDrive.EXPECT().ChangesList(context.Background(), watchChannelData.PageToken).Return(changeList, nil).MaxTimes(1) + watchChannelData = &model.WatchChannelData{ + ChannelID: "channelId1", + ResourceID: "resourceId1", + MMUserID: "userId1", + Expiration: 0, + Token: "token1", + PageToken: changeList.NewStartPageToken, + } + mockKvStore.EXPECT().StoreWatchChannelData("userId1", *watchChannelData).Return(nil) + mockGoogleClient.EXPECT().NewDriveActivityService(context.Background(), "userId1").Return(mockDriveActivity, nil) + mockKvStore.EXPECT().StoreLastActivityForFile("userId1", "fileId1", "2021-01-02T00:00:00.000Z").Return(nil) + }, + }, + "Ensure we only hit the drive activity api a maximum of 5 times": { + expectedStatusCode: http.StatusOK, + envSetup: func(te *TestEnvironment) { + watchChannelData := GetSampleWatchChannelData() + mockKvStore.EXPECT().GetWatchChannelData("userId1").Return(watchChannelData, nil).MaxTimes(2) + mockGoogleClient.EXPECT().NewDriveService(context.Background(), "userId1").Return(mockGoogleDrive, nil) + mockCluster.EXPECT().NewMutex("drive_watch_notifications_userId1").Return(pluginapi.NewClusterMutexMock(), nil) + te.mockAPI.On("KVSetWithOptions", "mutex_drive_watch_notifications_userId1", mock.Anything, mock.Anything).Return(true, nil) + changeList := GetSampleChangeList() + mockGoogleDrive.EXPECT().ChangesList(context.Background(), watchChannelData.PageToken).Return(changeList, nil).MaxTimes(1) + watchChannelData = &model.WatchChannelData{ + ChannelID: "channelId1", + ResourceID: "resourceId1", + MMUserID: "userId1", + Expiration: 0, + Token: "token1", + PageToken: changeList.NewStartPageToken, + } + mockKvStore.EXPECT().StoreWatchChannelData("userId1", *watchChannelData).Return(nil) + mockGoogleClient.EXPECT().NewDriveActivityService(context.Background(), "userId1").Return(mockDriveActivity, nil) + mockKvStore.EXPECT().GetLastActivityForFile("userId1", changeList.Changes[0].File.Id).Return(changeList.Changes[0].File.ModifiedTime, nil) + mockDriveActivity.EXPECT().Query(context.Background(), &driveactivity.QueryDriveActivityRequest{ + ItemName: fmt.Sprintf("items/%s", changeList.Changes[0].File.Id), + Filter: "time > \"" + changeList.Changes[0].File.ModifiedTime + "\"", + }).Return(&driveactivity.QueryDriveActivityResponse{Activities: []*driveactivity.DriveActivity{}, NextPageToken: "newPage"}, nil).MaxTimes(1) + mockDriveActivity.EXPECT().Query(context.Background(), &driveactivity.QueryDriveActivityRequest{ + ItemName: fmt.Sprintf("items/%s", changeList.Changes[0].File.Id), + Filter: "time > \"" + changeList.Changes[0].File.ModifiedTime + "\"", + PageToken: "newPage", + }).Return(&driveactivity.QueryDriveActivityResponse{Activities: []*driveactivity.DriveActivity{}, NextPageToken: "newPage"}, nil).MaxTimes(4) + mockKvStore.EXPECT().StoreLastActivityForFile("userId1", changeList.Changes[0].File.Id, changeList.Changes[0].File.ModifiedTime).Return(nil) + }, + }, + "Send one bot DM if there are more than 6 activities in a file": { + expectedStatusCode: http.StatusOK, + envSetup: func(te *TestEnvironment) { + watchChannelData := GetSampleWatchChannelData() + mockKvStore.EXPECT().GetWatchChannelData("userId1").Return(watchChannelData, nil).MaxTimes(2) + mockGoogleClient.EXPECT().NewDriveService(context.Background(), "userId1").Return(mockGoogleDrive, nil) + mockCluster.EXPECT().NewMutex("drive_watch_notifications_userId1").Return(pluginapi.NewClusterMutexMock(), nil) + te.mockAPI.On("KVSetWithOptions", "mutex_drive_watch_notifications_userId1", mock.Anything, mock.Anything).Return(true, nil) + changeList := GetSampleChangeList() + mockGoogleDrive.EXPECT().ChangesList(context.Background(), watchChannelData.PageToken).Return(changeList, nil).MaxTimes(1) + watchChannelData = &model.WatchChannelData{ + ChannelID: "channelId1", + ResourceID: "resourceId1", + MMUserID: "userId1", + Expiration: 0, + Token: "token1", + PageToken: changeList.NewStartPageToken, + } + mockKvStore.EXPECT().StoreWatchChannelData("userId1", *watchChannelData).Return(nil) + mockGoogleClient.EXPECT().NewDriveActivityService(context.Background(), "userId1").Return(mockDriveActivity, nil) + mockKvStore.EXPECT().GetLastActivityForFile("userId1", changeList.Changes[0].File.Id).Return(changeList.Changes[0].File.ModifiedTime, nil) + mockDriveActivity.EXPECT().Query(context.Background(), &driveactivity.QueryDriveActivityRequest{ + ItemName: fmt.Sprintf("items/%s", changeList.Changes[0].File.Id), + Filter: "time > \"" + changeList.Changes[0].File.ModifiedTime + "\"", + }).Return(&driveactivity.QueryDriveActivityResponse{Activities: []*driveactivity.DriveActivity{ + { + PrimaryActionDetail: &driveactivity.ActionDetail{ + Comment: &driveactivity.Comment{}, + }, + }, + { + PrimaryActionDetail: &driveactivity.ActionDetail{ + Comment: &driveactivity.Comment{}, + }, + }, + { + PrimaryActionDetail: &driveactivity.ActionDetail{ + Comment: &driveactivity.Comment{}, + }, + }, + { + PrimaryActionDetail: &driveactivity.ActionDetail{ + Comment: &driveactivity.Comment{}, + }, + }, + { + PrimaryActionDetail: &driveactivity.ActionDetail{ + Comment: &driveactivity.Comment{}, + }, + }, + { + PrimaryActionDetail: &driveactivity.ActionDetail{ + Comment: &driveactivity.Comment{}, + }, + }, + }, NextPageToken: ""}, nil).MaxTimes(1) + te.mockAPI.On("GetDirectChannel", "userId1", te.plugin.BotUserID).Return(&mattermostModel.Channel{Id: "channelId1"}, nil).Times(1) + te.mockAPI.On("CreatePost", mock.Anything).Return(nil, nil).Times(1) + mockKvStore.EXPECT().StoreLastActivityForFile("userId1", changeList.Changes[0].File.Id, changeList.Changes[0].File.ModifiedTime).Return(nil) + }, + }, + "Send a notification for a permission change on a file": { + expectedStatusCode: http.StatusOK, + envSetup: func(te *TestEnvironment) { + watchChannelData := GetSampleWatchChannelData() + mockKvStore.EXPECT().GetWatchChannelData("userId1").Return(watchChannelData, nil).MaxTimes(2) + mockGoogleClient.EXPECT().NewDriveService(context.Background(), "userId1").Return(mockGoogleDrive, nil) + mockCluster.EXPECT().NewMutex("drive_watch_notifications_userId1").Return(pluginapi.NewClusterMutexMock(), nil) + te.mockAPI.On("KVSetWithOptions", "mutex_drive_watch_notifications_userId1", mock.Anything, mock.Anything).Return(true, nil) + changeList := GetSampleChangeList() + mockGoogleDrive.EXPECT().ChangesList(context.Background(), watchChannelData.PageToken).Return(changeList, nil).MaxTimes(1) + watchChannelData = &model.WatchChannelData{ + ChannelID: "channelId1", + ResourceID: "resourceId1", + MMUserID: "userId1", + Expiration: 0, + Token: "token1", + PageToken: changeList.NewStartPageToken, + } + mockKvStore.EXPECT().StoreWatchChannelData("userId1", *watchChannelData).Return(nil) + mockGoogleClient.EXPECT().NewDriveActivityService(context.Background(), "userId1").Return(mockDriveActivity, nil) + mockKvStore.EXPECT().GetLastActivityForFile("userId1", changeList.Changes[0].File.Id).Return(changeList.Changes[0].File.ModifiedTime, nil) + mockDriveActivity.EXPECT().Query(context.Background(), &driveactivity.QueryDriveActivityRequest{ + ItemName: fmt.Sprintf("items/%s", changeList.Changes[0].File.Id), + Filter: "time > \"" + changeList.Changes[0].File.ModifiedTime + "\"", + }).Return(GetSampleDriveactivityPermissionResponse(), nil).MaxTimes(1) + te.mockAPI.On("GetConfig").Return(nil) + te.mockAPI.On("GetDirectChannel", "userId1", te.plugin.BotUserID).Return(&mattermostModel.Channel{Id: "channelId1"}, nil).Times(1) + post := &mattermostModel.Post{ + UserId: te.plugin.BotUserID, + ChannelId: "channelId1", + Message: "Someone shared an item with you", + Props: mattermostModel.StringInterface{ + "attachments": []any{map[string]any{ + "title": changeList.Changes[0].File.Name, + "title_link": changeList.Changes[0].File.WebViewLink, + "footer": "Google Drive for Mattermost", + "footer_icon": changeList.Changes[0].File.IconLink, + }}, + }, + } + te.mockAPI.On("CreatePost", post).Return(nil, nil).Times(1) + mockKvStore.EXPECT().StoreLastActivityForFile("userId1", changeList.Changes[0].File.Id, changeList.Changes[0].File.ModifiedTime).Return(nil) + }, + }, + "Send a notification for a comment on a file": { + expectedStatusCode: http.StatusOK, + envSetup: func(te *TestEnvironment) { + watchChannelData := GetSampleWatchChannelData() + mockKvStore.EXPECT().GetWatchChannelData("userId1").Return(watchChannelData, nil).MaxTimes(2) + mockGoogleClient.EXPECT().NewDriveService(context.Background(), "userId1").Return(mockGoogleDrive, nil) + mockCluster.EXPECT().NewMutex("drive_watch_notifications_userId1").Return(pluginapi.NewClusterMutexMock(), nil) + te.mockAPI.On("KVSetWithOptions", "mutex_drive_watch_notifications_userId1", mock.Anything, mock.Anything).Return(true, nil) + changeList := GetSampleChangeList() + mockGoogleDrive.EXPECT().ChangesList(context.Background(), watchChannelData.PageToken).Return(changeList, nil).MaxTimes(1) + watchChannelData = &model.WatchChannelData{ + ChannelID: "channelId1", + ResourceID: "resourceId1", + MMUserID: "userId1", + Expiration: 0, + Token: "token1", + PageToken: changeList.NewStartPageToken, + } + mockKvStore.EXPECT().StoreWatchChannelData("userId1", *watchChannelData).Return(nil) + mockGoogleClient.EXPECT().NewDriveActivityService(context.Background(), "userId1").Return(mockDriveActivity, nil) + file := changeList.Changes[0].File + mockKvStore.EXPECT().GetLastActivityForFile("userId1", file.Id).Return(file.ModifiedTime, nil) + activityResponse := GetSampleDriveactivityCommentResponse() + mockDriveActivity.EXPECT().Query(context.Background(), &driveactivity.QueryDriveActivityRequest{ + ItemName: fmt.Sprintf("items/%s", file.Id), + Filter: "time > \"" + file.ModifiedTime + "\"", + }).Return(activityResponse, nil).MaxTimes(1) + commentId := activityResponse.Activities[0].Targets[0].FileComment.LegacyCommentId + comment := GetSampleComment(commentId) + mockGoogleDrive.EXPECT().GetComments(context.Background(), file.Id, commentId).Return(comment, nil) + siteUrl := "http://localhost" + te.mockAPI.On("GetConfig").Return(&mattermostModel.Config{ServiceSettings: mattermostModel.ServiceSettings{SiteURL: &siteUrl}}) + te.mockAPI.On("GetDirectChannel", "userId1", te.plugin.BotUserID).Return(&mattermostModel.Channel{Id: "channelId1"}, nil).Times(1) + post := &mattermostModel.Post{ + UserId: te.plugin.BotUserID, + ChannelId: "channelId1", + Message: "", + Props: mattermostModel.StringInterface{ + "attachments": []any{ + map[string]any{ + "pretext": fmt.Sprintf("%s commented on %s %s", comment.Author.DisplayName, utils.GetInlineImage("File icon:", file.IconLink), utils.GetHyperlink(file.Name, file.WebViewLink)), + "text": fmt.Sprintf("%s\n> %s", "", comment.Content), + "actions": []any{ + map[string]any{ + "name": "Reply to comment", + "integration": map[string]any{ + "url": fmt.Sprintf("%s/plugins/%s/api/v1/reply_dialog", siteUrl, Manifest.Id), + "context": map[string]any{ + "commentID": comment.Id, + "fileID": file.Id, + }, + }, + }, + }, + }, + }, + }, + } + te.mockAPI.On("CreatePost", post).Return(nil, nil).Times(1) + mockKvStore.EXPECT().StoreLastActivityForFile("userId1", changeList.Changes[0].File.Id, changeList.Changes[0].File.ModifiedTime).Return(nil) + }, + }, + } { + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + te := SetupTestEnvironment(t) + defer te.Cleanup(t) + + te.plugin.KVStore = mockKvStore + te.plugin.GoogleClient = mockGoogleClient + te.plugin.initializeAPI() + + test.envSetup(te) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/webhook?userID=userId1", nil) + r.Header.Set("X-Goog-Resource-State", "change") + r.Header.Set("X-Goog-Channel-Token", "token1") + ctx := &Context{ + Ctx: context.Background(), + UserID: "userId1", + Log: nil, + } + if test.modifyRequest != nil { + r = test.modifyRequest(r) + } + te.plugin.handleDriveWatchNotifications(ctx, w, r) + + result := w.Result() + require.NotNil(t, result) + defer result.Body.Close() + assert.Equal(test.expectedStatusCode, result.StatusCode) + }) + } +} + +func TestFileCreationEndpoint(t *testing.T) { + mockKvStore, mockGoogleClient, mockGoogleDrive, _, mockGoogleDocs, mockGoogleSheets, mockGoogleSlides, _, _ := GetMockSetup(t) + + for name, test := range map[string]struct { + expectedStatusCode int + submission *mattermostModel.SubmitDialogRequest + envSetup func(ctx context.Context, te *TestEnvironment) + fileType string + }{ + "No FileType parameter provided": { + expectedStatusCode: http.StatusBadRequest, + submission: &mattermostModel.SubmitDialogRequest{ + Submission: map[string]any{ + "name": "file name", + "file_access": "all_comment", + "message": "file message", + "share_in_channel": true, + }, + }, + envSetup: func(ctx context.Context, te *TestEnvironment) { + te.mockAPI.On("LogError", mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string")).Maybe() + }, + fileType: "", + }, + "Create a doc with all_comment access": { + fileType: "doc", + expectedStatusCode: http.StatusOK, + submission: &mattermostModel.SubmitDialogRequest{ + Submission: map[string]any{ + "name": "file name", + "file_access": "all_comment", + "message": "file message", + "share_in_channel": false, + }, + }, + envSetup: func(ctx context.Context, te *TestEnvironment) { + mockGoogleClient.EXPECT().NewDocsService(ctx, "userId1").Return(mockGoogleDocs, nil) + doc := GetSampleDoc() + mockGoogleDocs.EXPECT().Create(ctx, &docs.Document{ + Title: "file name", + }).Return(doc, nil) + mockGoogleClient.EXPECT().NewDriveService(ctx, "userId1").Return(mockGoogleDrive, nil).Times(2) + te.mockAPI.On("GetConfig").Return(nil) + mockGoogleDrive.EXPECT().CreatePermission(ctx, doc.DocumentId, &drive.Permission{ + Role: "commenter", + Type: "anyone", + }).Return(&drive.Permission{}, nil).MaxTimes(1) + file := GetSampleFile(doc.DocumentId) + mockGoogleDrive.EXPECT().GetFile(ctx, doc.DocumentId).Return(file, nil) + te.mockAPI.On("GetDirectChannel", "userId1", te.plugin.BotUserID).Return(&mattermostModel.Channel{Id: "channelId1"}, nil).Times(1) + te.mockAPI.On("CreatePost", mock.Anything).Return(nil, nil).Times(1) + }, + }, + "Create a sheet with all_edit access": { + fileType: "sheet", + expectedStatusCode: http.StatusOK, + submission: &mattermostModel.SubmitDialogRequest{ + Submission: map[string]any{ + "name": "file name", + "file_access": "all_edit", + "message": "file message", + "share_in_channel": false, + }, + }, + envSetup: func(ctx context.Context, te *TestEnvironment) { + mockGoogleClient.EXPECT().NewSheetsService(ctx, "userId1").Return(mockGoogleSheets, nil) + sheet := GetSampleSheet() + mockGoogleSheets.EXPECT().Create(ctx, &sheets.Spreadsheet{ + Properties: &sheets.SpreadsheetProperties{ + Title: "file name", + }, + }).Return(sheet, nil) + mockGoogleClient.EXPECT().NewDriveService(ctx, "userId1").Return(mockGoogleDrive, nil).Times(2) + te.mockAPI.On("GetConfig").Return(nil) + mockGoogleDrive.EXPECT().CreatePermission(ctx, sheet.SpreadsheetId, &drive.Permission{ + Role: "writer", + Type: "anyone", + }).Return(&drive.Permission{}, nil).MaxTimes(1) + file := GetSampleFile(sheet.SpreadsheetId) + mockGoogleDrive.EXPECT().GetFile(ctx, sheet.SpreadsheetId).Return(file, nil) + te.mockAPI.On("GetDirectChannel", "userId1", te.plugin.BotUserID).Return(&mattermostModel.Channel{Id: "channelId1"}, nil).Times(1) + te.mockAPI.On("CreatePost", mock.Anything).Return(nil, nil).Times(1) + }, + }, + "Create a presentation with all_view access": { + fileType: "slide", + expectedStatusCode: http.StatusOK, + submission: &mattermostModel.SubmitDialogRequest{ + Submission: map[string]any{ + "name": "file name", + "file_access": "all_view", + "message": "file message", + "share_in_channel": false, + }, + }, + envSetup: func(ctx context.Context, te *TestEnvironment) { + mockGoogleClient.EXPECT().NewSlidesService(ctx, "userId1").Return(mockGoogleSlides, nil) + presentation := GetSamplePresentation() + mockGoogleSlides.EXPECT().Create(ctx, &slides.Presentation{ + Title: "file name", + }).Return(presentation, nil) + mockGoogleClient.EXPECT().NewDriveService(ctx, "userId1").Return(mockGoogleDrive, nil).Times(2) + te.mockAPI.On("GetConfig").Return(nil) + mockGoogleDrive.EXPECT().CreatePermission(ctx, presentation.PresentationId, &drive.Permission{ + Role: "reader", + Type: "anyone", + }).Return(&drive.Permission{}, nil).MaxTimes(1) + file := GetSampleFile(presentation.PresentationId) + mockGoogleDrive.EXPECT().GetFile(ctx, presentation.PresentationId).Return(file, nil) + te.mockAPI.On("GetDirectChannel", "userId1", te.plugin.BotUserID).Return(&mattermostModel.Channel{Id: "channelId1"}, nil).Times(1) + te.mockAPI.On("CreatePost", mock.Anything).Return(nil, nil).Times(1) + }, + }, + "Create a private doc": { + fileType: "doc", + expectedStatusCode: http.StatusOK, + submission: &mattermostModel.SubmitDialogRequest{ + Submission: map[string]any{ + "name": "file name", + "file_access": "private", + "message": "file message", + "share_in_channel": false, + }, + }, + envSetup: func(ctx context.Context, te *TestEnvironment) { + mockGoogleClient.EXPECT().NewDocsService(ctx, "userId1").Return(mockGoogleDocs, nil) + doc := GetSampleDoc() + mockGoogleDocs.EXPECT().Create(ctx, &docs.Document{ + Title: "file name", + }).Return(doc, nil) + mockGoogleClient.EXPECT().NewDriveService(ctx, "userId1").Return(mockGoogleDrive, nil).Times(2) + te.mockAPI.On("GetConfig").Return(nil) + file := GetSampleFile(doc.DocumentId) + mockGoogleDrive.EXPECT().GetFile(ctx, doc.DocumentId).Return(file, nil) + te.mockAPI.On("GetDirectChannel", "userId1", te.plugin.BotUserID).Return(&mattermostModel.Channel{Id: "channelId1"}, nil).Times(1) + te.mockAPI.On("CreatePost", mock.Anything).Return(nil, nil).Times(1) + }, + }, + "Create a doc with all_comment access and share in channel": { + fileType: "doc", + expectedStatusCode: http.StatusOK, + submission: &mattermostModel.SubmitDialogRequest{ + ChannelId: "channelId1", + Submission: map[string]any{ + "name": "file name", + "file_access": "all_comment", + "message": "file message", + "share_in_channel": true, + }, + }, + envSetup: func(ctx context.Context, te *TestEnvironment) { + mockGoogleClient.EXPECT().NewDocsService(ctx, "userId1").Return(mockGoogleDocs, nil) + doc := GetSampleDoc() + mockGoogleDocs.EXPECT().Create(ctx, &docs.Document{ + Title: "file name", + }).Return(doc, nil) + mockGoogleClient.EXPECT().NewDriveService(ctx, "userId1").Return(mockGoogleDrive, nil).Times(2) + te.mockAPI.On("GetConfig").Return(nil) + mockGoogleDrive.EXPECT().CreatePermission(ctx, doc.DocumentId, &drive.Permission{ + Role: "commenter", + Type: "anyone", + }).Return(&drive.Permission{}, nil).MaxTimes(1) + file := GetSampleFile(doc.DocumentId) + mockGoogleDrive.EXPECT().GetFile(ctx, doc.DocumentId).Return(file, nil) + createdTime, err := time.Parse(time.RFC3339, file.CreatedTime) + require.NoError(t, err) + post := &mattermostModel.Post{ + UserId: te.plugin.BotUserID, + ChannelId: "channelId1", + Message: "file message", + Props: map[string]any{ + "attachments": []any{map[string]any{ + "author_name": file.Owners[0].DisplayName, + "author_icon": file.Owners[0].PhotoLink, + "title": file.Name, + "title_link": file.WebViewLink, + "footer": fmt.Sprintf("Google Drive for Mattermost | %s", createdTime), + "footer_icon": file.IconLink, + }}, }, - DriveId: "driveId1", - Removed: false, - Time: "2021-01-01T00:00:00.000Z", + } + te.mockAPI.On("CreatePost", post).Return(nil, nil).Times(1) + }, + }, + "Create a doc and allow channels members to comment": { + fileType: "doc", + expectedStatusCode: http.StatusOK, + submission: &mattermostModel.SubmitDialogRequest{ + ChannelId: "channelId1", + Submission: map[string]any{ + "name": "file name", + "file_access": "members_comment", + "message": "file message", + "share_in_channel": true, }, }, - NewStartPageToken: "newPageToken2", - NextPageToken: "", - }, nil) - mockKvStore.EXPECT().GetLastActivityForFile("userId1", "fileId1").Return("2020-01-01T00:00:00.000Z", nil) - watchChannelData = &model.WatchChannelData{ - ChannelID: "channelId1", - ResourceID: "resourceId1", - MMUserID: "userId1", - Expiration: 0, - Token: "token1", - PageToken: "newPageToken2", - } - mockKvStore.EXPECT().StoreWatchChannelData("userId1", *watchChannelData).Return(nil) - mockDriveActivity.EXPECT().Query(context.Background(), &driveactivity.QueryDriveActivityRequest{ - ItemName: fmt.Sprintf("items/%s", "fileId1"), - Filter: "time > \"2020-01-01T00:00:00.000Z\"", - }).Return(&driveactivity.QueryDriveActivityResponse{}, nil) - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodPost, "/webhook?userID=userId1", nil) - r.Header.Set("X-Goog-Resource-State", "change") - r.Header.Set("X-Goog-Channel-Token", "token1") - ctx := &Context{ - Ctx: context.Background(), - UserID: "userId1", - Log: nil, - } - te.plugin.handleDriveWatchNotifications(ctx, w, r) - - result := w.Result() - require.NotNil(t, result) - defer result.Body.Close() - - assert.Equal(http.StatusOK, result.StatusCode) - }) + envSetup: func(ctx context.Context, te *TestEnvironment) { + mockGoogleClient.EXPECT().NewDocsService(ctx, "userId1").Return(mockGoogleDocs, nil) + doc := GetSampleDoc() + mockGoogleDocs.EXPECT().Create(ctx, &docs.Document{ + Title: "file name", + }).Return(doc, nil) + mockGoogleClient.EXPECT().NewDriveService(ctx, "userId1").Return(mockGoogleDrive, nil).Times(2) + te.mockAPI.On("GetConfig").Return(nil) + users := []*mattermostModel.User{ + { + Email: "user1@mattermost.com", + IsBot: false, + }, + { + Email: "user2@mattermost.com", + IsBot: false, + }, + } + te.mockAPI.On("GetUsersInChannel", "channelId1", "username", 0, 100).Return(users, nil).Times(1) + te.mockAPI.On("GetUsersInChannel", "channelId1", "username", 1, 100).Return([]*mattermostModel.User{}, nil).Times(1) + + mockGoogleDrive.EXPECT().CreatePermission(ctx, doc.DocumentId, &drive.Permission{ + Role: "commenter", + EmailAddress: users[0].Email, + Type: "user", + }).Return(&drive.Permission{}, nil).MaxTimes(1) + mockGoogleDrive.EXPECT().CreatePermission(ctx, doc.DocumentId, &drive.Permission{ + Role: "commenter", + EmailAddress: users[1].Email, + Type: "user", + }).Return(&drive.Permission{}, nil).MaxTimes(1) + file := GetSampleFile(doc.DocumentId) + mockGoogleDrive.EXPECT().GetFile(ctx, doc.DocumentId).Return(file, nil) + createdTime, err := time.Parse(time.RFC3339, file.CreatedTime) + require.NoError(t, err) + post := &mattermostModel.Post{ + UserId: te.plugin.BotUserID, + ChannelId: "channelId1", + Message: "file message", + Props: map[string]any{ + "attachments": []any{map[string]any{ + "author_name": file.Owners[0].DisplayName, + "author_icon": file.Owners[0].PhotoLink, + "title": file.Name, + "title_link": file.WebViewLink, + "footer": fmt.Sprintf("Google Drive for Mattermost | %s", createdTime), + "footer_icon": file.IconLink, + }}, + }, + } + te.mockAPI.On("CreatePost", post).Return(nil, nil).Times(1) + }, + }, + } { + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + te := SetupTestEnvironment(t) + defer te.Cleanup(t) + + te.plugin.KVStore = mockKvStore + te.plugin.GoogleClient = mockGoogleClient + te.plugin.initializeAPI() + + w := httptest.NewRecorder() + + var body bytes.Buffer + err := json.NewEncoder(&body).Encode(test.submission) + if err != nil { + require.NoError(t, err) + } + r := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/create?type=%s", test.fileType), &body) + r.Header.Set("Mattermost-User-ID", "userId1") + ctx, _ := te.plugin.createContext(w, r) + + test.envSetup(ctx.Ctx, te) + te.plugin.handleFileCreation(ctx, w, r) + + result := w.Result() + require.NotNil(t, result) + defer result.Body.Close() + assert.Equal(test.expectedStatusCode, result.StatusCode) + }) + } } diff --git a/server/plugin/create.go b/server/plugin/create.go index 449804d..f6ce25b 100644 --- a/server/plugin/create.go +++ b/server/plugin/create.go @@ -26,7 +26,10 @@ func (p *Plugin) sendFileCreatedMessage(ctx context.Context, channelID, fileID, return errors.Wrap(err, "failed to fetch file") } - createdTime, _ := time.Parse(time.RFC3339, file.CreatedTime) + createdTime, err := time.Parse(time.RFC3339, file.CreatedTime) + if err != nil { + return errors.Wrap(err, "failed to parse created time") + } if shareInChannel { post := model.Post{ UserId: p.BotUserID, diff --git a/server/plugin/google/google.go b/server/plugin/google/google.go index 8abb17c..0f95fbc 100644 --- a/server/plugin/google/google.go +++ b/server/plugin/google/google.go @@ -126,7 +126,7 @@ func (g *Client) NewDriveV2Service(ctx context.Context, userID string) (DriveV2I }, nil } -func (g *Client) NewDocsService(ctx context.Context, userID string) (*DocsService, error) { +func (g *Client) NewDocsService(ctx context.Context, userID string) (DocsInterface, error) { authToken, err := g.GetGoogleUserToken(userID) if err != nil { return nil, err @@ -154,7 +154,7 @@ func (g *Client) NewDocsService(ctx context.Context, userID string) (*DocsServic }, nil } -func (g *Client) NewSlidesService(ctx context.Context, userID string) (*SlidesService, error) { +func (g *Client) NewSlidesService(ctx context.Context, userID string) (SlidesInterface, error) { authToken, err := g.GetGoogleUserToken(userID) if err != nil { return nil, err @@ -182,7 +182,7 @@ func (g *Client) NewSlidesService(ctx context.Context, userID string) (*SlidesSe }, nil } -func (g *Client) NewSheetsService(ctx context.Context, userID string) (*SheetsService, error) { +func (g *Client) NewSheetsService(ctx context.Context, userID string) (SheetsInterface, error) { authToken, err := g.GetGoogleUserToken(userID) if err != nil { return nil, err diff --git a/server/plugin/google/interfaces.go b/server/plugin/google/interfaces.go index da429ff..da65aa8 100644 --- a/server/plugin/google/interfaces.go +++ b/server/plugin/google/interfaces.go @@ -4,18 +4,21 @@ import ( "context" "golang.org/x/oauth2" + "google.golang.org/api/docs/v1" driveV2 "google.golang.org/api/drive/v2" "google.golang.org/api/drive/v3" "google.golang.org/api/driveactivity/v2" "google.golang.org/api/googleapi" + "google.golang.org/api/sheets/v4" + "google.golang.org/api/slides/v1" ) type ClientInterface interface { NewDriveService(ctx context.Context, userID string) (DriveInterface, error) NewDriveV2Service(ctx context.Context, userID string) (DriveV2Interface, error) - NewDocsService(ctx context.Context, userID string) (*DocsService, error) - NewSlidesService(ctx context.Context, userID string) (*SlidesService, error) - NewSheetsService(ctx context.Context, userID string) (*SheetsService, error) + NewDocsService(ctx context.Context, userID string) (DocsInterface, error) + NewSlidesService(ctx context.Context, userID string) (SlidesInterface, error) + NewSheetsService(ctx context.Context, userID string) (SheetsInterface, error) NewDriveActivityService(ctx context.Context, userID string) (DriveActivityInterface, error) GetGoogleUserToken(userID string) (*oauth2.Token, error) ReloadRateLimits(newQueriesPerMinute int, newBurstSize int) @@ -41,3 +44,15 @@ type DriveV2Interface interface { type DriveActivityInterface interface { Query(ctx context.Context, request *driveactivity.QueryDriveActivityRequest) (*driveactivity.QueryDriveActivityResponse, error) } + +type DocsInterface interface { + Create(ctx context.Context, document *docs.Document) (*docs.Document, error) +} + +type SheetsInterface interface { + Create(ctx context.Context, spreadsheet *sheets.Spreadsheet) (*sheets.Spreadsheet, error) +} + +type SlidesInterface interface { + Create(ctx context.Context, presentation *slides.Presentation) (*slides.Presentation, error) +} diff --git a/server/plugin/google/mocks/mock_docs.go b/server/plugin/google/mocks/mock_docs.go new file mode 100644 index 0000000..3f76b0c --- /dev/null +++ b/server/plugin/google/mocks/mock_docs.go @@ -0,0 +1,51 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/google (interfaces: DocsInterface) + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + docs "google.golang.org/api/docs/v1" +) + +// MockDocsInterface is a mock of DocsInterface interface. +type MockDocsInterface struct { + ctrl *gomock.Controller + recorder *MockDocsInterfaceMockRecorder +} + +// MockDocsInterfaceMockRecorder is the mock recorder for MockDocsInterface. +type MockDocsInterfaceMockRecorder struct { + mock *MockDocsInterface +} + +// NewMockDocsInterface creates a new mock instance. +func NewMockDocsInterface(ctrl *gomock.Controller) *MockDocsInterface { + mock := &MockDocsInterface{ctrl: ctrl} + mock.recorder = &MockDocsInterfaceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockDocsInterface) EXPECT() *MockDocsInterfaceMockRecorder { + return m.recorder +} + +// Create mocks base method. +func (m *MockDocsInterface) Create(arg0 context.Context, arg1 *docs.Document) (*docs.Document, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Create", arg0, arg1) + ret0, _ := ret[0].(*docs.Document) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Create indicates an expected call of Create. +func (mr *MockDocsInterfaceMockRecorder) Create(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockDocsInterface)(nil).Create), arg0, arg1) +} diff --git a/server/plugin/google/mocks/mock_google.go b/server/plugin/google/mocks/mock_google.go index d47ffdb..95c6609 100644 --- a/server/plugin/google/mocks/mock_google.go +++ b/server/plugin/google/mocks/mock_google.go @@ -52,10 +52,10 @@ func (mr *MockClientInterfaceMockRecorder) GetGoogleUserToken(arg0 interface{}) } // NewDocsService mocks base method. -func (m *MockClientInterface) NewDocsService(arg0 context.Context, arg1 string) (*google.DocsService, error) { +func (m *MockClientInterface) NewDocsService(arg0 context.Context, arg1 string) (google.DocsInterface, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewDocsService", arg0, arg1) - ret0, _ := ret[0].(*google.DocsService) + ret0, _ := ret[0].(google.DocsInterface) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -112,10 +112,10 @@ func (mr *MockClientInterfaceMockRecorder) NewDriveV2Service(arg0, arg1 interfac } // NewSheetsService mocks base method. -func (m *MockClientInterface) NewSheetsService(arg0 context.Context, arg1 string) (*google.SheetsService, error) { +func (m *MockClientInterface) NewSheetsService(arg0 context.Context, arg1 string) (google.SheetsInterface, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewSheetsService", arg0, arg1) - ret0, _ := ret[0].(*google.SheetsService) + ret0, _ := ret[0].(google.SheetsInterface) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -127,10 +127,10 @@ func (mr *MockClientInterfaceMockRecorder) NewSheetsService(arg0, arg1 interface } // NewSlidesService mocks base method. -func (m *MockClientInterface) NewSlidesService(arg0 context.Context, arg1 string) (*google.SlidesService, error) { +func (m *MockClientInterface) NewSlidesService(arg0 context.Context, arg1 string) (google.SlidesInterface, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewSlidesService", arg0, arg1) - ret0, _ := ret[0].(*google.SlidesService) + ret0, _ := ret[0].(google.SlidesInterface) ret1, _ := ret[1].(error) return ret0, ret1 } diff --git a/server/plugin/google/mocks/mock_sheets.go b/server/plugin/google/mocks/mock_sheets.go new file mode 100644 index 0000000..3ae4d32 --- /dev/null +++ b/server/plugin/google/mocks/mock_sheets.go @@ -0,0 +1,51 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/google (interfaces: SheetsInterface) + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + sheets "google.golang.org/api/sheets/v4" +) + +// MockSheetsInterface is a mock of SheetsInterface interface. +type MockSheetsInterface struct { + ctrl *gomock.Controller + recorder *MockSheetsInterfaceMockRecorder +} + +// MockSheetsInterfaceMockRecorder is the mock recorder for MockSheetsInterface. +type MockSheetsInterfaceMockRecorder struct { + mock *MockSheetsInterface +} + +// NewMockSheetsInterface creates a new mock instance. +func NewMockSheetsInterface(ctrl *gomock.Controller) *MockSheetsInterface { + mock := &MockSheetsInterface{ctrl: ctrl} + mock.recorder = &MockSheetsInterfaceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockSheetsInterface) EXPECT() *MockSheetsInterfaceMockRecorder { + return m.recorder +} + +// Create mocks base method. +func (m *MockSheetsInterface) Create(arg0 context.Context, arg1 *sheets.Spreadsheet) (*sheets.Spreadsheet, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Create", arg0, arg1) + ret0, _ := ret[0].(*sheets.Spreadsheet) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Create indicates an expected call of Create. +func (mr *MockSheetsInterfaceMockRecorder) Create(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockSheetsInterface)(nil).Create), arg0, arg1) +} diff --git a/server/plugin/google/mocks/mock_slides.go b/server/plugin/google/mocks/mock_slides.go new file mode 100644 index 0000000..e8fefd2 --- /dev/null +++ b/server/plugin/google/mocks/mock_slides.go @@ -0,0 +1,51 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/google (interfaces: SlidesInterface) + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + slides "google.golang.org/api/slides/v1" +) + +// MockSlidesInterface is a mock of SlidesInterface interface. +type MockSlidesInterface struct { + ctrl *gomock.Controller + recorder *MockSlidesInterfaceMockRecorder +} + +// MockSlidesInterfaceMockRecorder is the mock recorder for MockSlidesInterface. +type MockSlidesInterfaceMockRecorder struct { + mock *MockSlidesInterface +} + +// NewMockSlidesInterface creates a new mock instance. +func NewMockSlidesInterface(ctrl *gomock.Controller) *MockSlidesInterface { + mock := &MockSlidesInterface{ctrl: ctrl} + mock.recorder = &MockSlidesInterfaceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockSlidesInterface) EXPECT() *MockSlidesInterfaceMockRecorder { + return m.recorder +} + +// Create mocks base method. +func (m *MockSlidesInterface) Create(arg0 context.Context, arg1 *slides.Presentation) (*slides.Presentation, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Create", arg0, arg1) + ret0, _ := ret[0].(*slides.Presentation) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Create indicates an expected call of Create. +func (mr *MockSlidesInterfaceMockRecorder) Create(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockSlidesInterface)(nil).Create), arg0, arg1) +} diff --git a/server/plugin/plugin_utils.go b/server/plugin/plugin_utils.go index 173692a..e745d21 100644 --- a/server/plugin/plugin_utils.go +++ b/server/plugin/plugin_utils.go @@ -8,7 +8,7 @@ import ( // CreateBotDMPost posts a direct message using the bot account. // Any error are not returned and instead logged. func (p *Plugin) createBotDMPost(userID, message string, props map[string]any) { - channel, err := p.Client.Channel.GetDirect(userID, p.BotUserID) + channel, err := p.API.GetDirectChannel(userID, p.BotUserID) if err != nil { p.Client.Log.Warn("Couldn't get bot's DM channel", "userID", userID, "error", err.Error()) return @@ -21,7 +21,7 @@ func (p *Plugin) createBotDMPost(userID, message string, props map[string]any) { Props: props, } - if err = p.Client.Post.CreatePost(post); err != nil { + if _, err = p.API.CreatePost(post); err != nil { p.Client.Log.Warn("Failed to create DM post", "userID", userID, "post", post, "error", err.Error()) return } diff --git a/server/plugin/test_utils.go b/server/plugin/test_utils.go index a9fc7e5..72a2443 100644 --- a/server/plugin/test_utils.go +++ b/server/plugin/test_utils.go @@ -4,9 +4,15 @@ import ( "testing" "github.com/golang/mock/gomock" + "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/model" mock_pluginapi "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/pluginapi/mocks" "github.com/mattermost/mattermost/server/public/plugin/plugintest" "github.com/mattermost/mattermost/server/public/pluginapi" + "google.golang.org/api/docs/v1" + "google.golang.org/api/drive/v3" + "google.golang.org/api/driveactivity/v2" + "google.golang.org/api/sheets/v4" + "google.golang.org/api/slides/v1" mock_google "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/google/mocks" @@ -19,7 +25,9 @@ type TestEnvironment struct { } func SetupTestEnvironment(t *testing.T) *TestEnvironment { - p := Plugin{} + p := Plugin{ + BotUserID: "bot_user_id", + } e := &TestEnvironment{ plugin: &p, @@ -41,7 +49,7 @@ func (e *TestEnvironment) ResetMocks(t *testing.T) { } // revive:disable-next-line:unexported-return -func GetMockSetup(t *testing.T) (*mock_store.MockKVStore, *mock_google.MockClientInterface, *mock_google.MockDriveInterface, *mock_google.MockDriveActivityInterface, *mock_pluginapi.MockClusterMutex, *mock_pluginapi.MockCluster) { +func GetMockSetup(t *testing.T) (*mock_store.MockKVStore, *mock_google.MockClientInterface, *mock_google.MockDriveInterface, *mock_google.MockDriveActivityInterface, *mock_google.MockDocsInterface, *mock_google.MockSheetsInterface, *mock_google.MockSlidesInterface, *mock_pluginapi.MockClusterMutex, *mock_pluginapi.MockCluster) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -49,8 +57,148 @@ func GetMockSetup(t *testing.T) (*mock_store.MockKVStore, *mock_google.MockClien mockGoogleClient := mock_google.NewMockClientInterface(ctrl) mockGoogleDrive := mock_google.NewMockDriveInterface(ctrl) mockDriveActivity := mock_google.NewMockDriveActivityInterface(ctrl) + mockGoogleDocs := mock_google.NewMockDocsInterface(ctrl) + mockGoogleSheets := mock_google.NewMockSheetsInterface(ctrl) + mockGoogleSlides := mock_google.NewMockSlidesInterface(ctrl) mockClusterMutex := mock_pluginapi.NewMockClusterMutex(ctrl) mockCluster := mock_pluginapi.NewMockCluster(ctrl) - return mockKvStore, mockGoogleClient, mockGoogleDrive, mockDriveActivity, mockClusterMutex, mockCluster + return mockKvStore, mockGoogleClient, mockGoogleDrive, mockDriveActivity, mockGoogleDocs, mockGoogleSheets, mockGoogleSlides, mockClusterMutex, mockCluster +} + +func GetSampleChangeList() *drive.ChangeList { + return &drive.ChangeList{ + Changes: []*drive.Change{ + { + FileId: "fileId1", + Kind: "drive#change", + File: GetSampleFile("fileId1"), + DriveId: "driveId1", + Removed: false, + Time: "2021-01-01T00:00:00.000Z", + }, + }, + NewStartPageToken: "newPageToken2", + NextPageToken: "", + } +} + +func GetSampleDriveactivityCommentResponse() *driveactivity.QueryDriveActivityResponse { + return &driveactivity.QueryDriveActivityResponse{ + Activities: []*driveactivity.DriveActivity{ + { + PrimaryActionDetail: &driveactivity.ActionDetail{ + Comment: &driveactivity.Comment{ + Post: &driveactivity.Post{ + Subtype: "ADDED", + }, + }, + }, + Actors: []*driveactivity.Actor{ + { + User: &driveactivity.User{ + KnownUser: &driveactivity.KnownUser{ + IsCurrentUser: false, + }, + }, + }, + }, + Targets: []*driveactivity.Target{ + { + FileComment: &driveactivity.FileComment{ + LegacyCommentId: "commentId1", + LegacyDiscussionId: "commentId1", + }, + }, + }, + }, + }, + NextPageToken: "", + } +} + +func GetSampleDriveactivityPermissionResponse() *driveactivity.QueryDriveActivityResponse { + return &driveactivity.QueryDriveActivityResponse{ + Activities: []*driveactivity.DriveActivity{ + { + PrimaryActionDetail: &driveactivity.ActionDetail{ + PermissionChange: &driveactivity.PermissionChange{}, + }, + Actors: []*driveactivity.Actor{ + { + User: &driveactivity.User{ + KnownUser: &driveactivity.KnownUser{ + IsCurrentUser: false, + }, + }, + }, + }, + Targets: []*driveactivity.Target{}, + }, + }, + NextPageToken: "", + } +} + +func GetSampleComment(commentId string) *drive.Comment { + return &drive.Comment{ + Content: "comment1", + Id: commentId, + Author: &drive.User{ + DisplayName: "author1", + }, + } +} + +func GetSampleWatchChannelData() *model.WatchChannelData { + return &model.WatchChannelData{ + ChannelID: "channelId1", + ResourceID: "resourceId1", + MMUserID: "userId1", + Expiration: 0, + Token: "token1", + PageToken: "pageToken1", + } +} + +func GetSampleDoc() *docs.Document { + return &docs.Document{ + Title: "doc1", + DocumentId: "docId1", + } +} + +func GetSampleSheet() *sheets.Spreadsheet { + return &sheets.Spreadsheet{ + Properties: &sheets.SpreadsheetProperties{ + Title: "sheet1", + }, + SpreadsheetId: "sheetId1", + } +} + +func GetSamplePresentation() *slides.Presentation { + return &slides.Presentation{ + Title: "presentation1", + PresentationId: "presentationId1", + } +} + +func GetSampleFile(fileId string) *drive.File { + return &drive.File{ + Id: fileId, + ViewedByMeTime: "2020-01-01T00:00:00.000Z", + ModifiedTime: "2021-01-01T00:00:00.000Z", + CreatedTime: "2021-01-01T00:00:00.000Z", + SharingUser: &drive.User{}, + WebViewLink: "https://drive.google.com/file/d/fileId1/view", + Name: "file1", + IconLink: "https://drive.google.com/file/d/fileId1/view/icon", + Owners: []*drive.User{ + { + DisplayName: "owner1", + PhotoLink: "https://drive.google.com/file/d/fileId1/view/photo", + }, + }, + } } From e4415026fd61c5073ad26e2f20539757375052bf Mon Sep 17 00:00:00 2001 From: Benjamin Cooke Date: Mon, 4 Nov 2024 18:38:59 -0500 Subject: [PATCH 13/17] unit tests for the watchchanneldata job, upload file and upload multiple files --- Makefile | 14 ++ server/plugin/api.go | 78 ++++++---- server/plugin/api_test.go | 158 ++++++++++++++++++++ server/plugin/kvstore/google.go | 10 ++ server/plugin/kvstore/kvstore.go | 1 + server/plugin/kvstore/mocks/mock_kvstore.go | 15 ++ server/plugin/notifications.go | 4 +- server/plugin/plugin.go | 8 +- server/plugin/plugin_test.go | 93 ++++++++++++ 9 files changed, 342 insertions(+), 39 deletions(-) create mode 100644 server/plugin/plugin_test.go diff --git a/Makefile b/Makefile index 18e745a..4ddc0cd 100644 --- a/Makefile +++ b/Makefile @@ -291,6 +291,20 @@ logs: logs-watch: ./build/bin/pluginctl logs-watch $(PLUGIN_ID) +## Generate mocks +mock: +ifneq ($(HAS_SERVER),) + mockgen -destination=server/plugin/google/mocks/mock_drive_activity.go -package=mocks github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/google DriveActivityInterface + mockgen -destination=server/plugin/google/mocks/mock_google.go -package=mocks github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/google ClientInterface + mockgen -destination=server/plugin/pluginapi/mocks/mock_cluster_mutex.go -package=mocks github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/pluginapi ClusterMutex + mockgen -destination=server/plugin/pluginapi/mocks/mock_cluster.go -package=mocks github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/pluginapi Cluster + mockgen -destination=server/plugin/google/mocks/mock_drive.go -package=mocks github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/google DriveInterface + mockgen -destination=server/plugin/kvstore/mocks/mock_kvstore.go -package=mocks github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/kvstore KVStore + mockgen -destination=server/plugin/google/mocks/mock_sheets.go -package=mocks github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/google SheetsInterface + mockgen -destination=server/plugin/google/mocks/mock_docs.go -package=mocks github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/google DocsInterface + mockgen -destination=server/plugin/google/mocks/mock_slides.go -package=mocks github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/google SlidesInterface +endif + # Help documentation à la https://marmelab.com/blog/2016/02/29/auto-documented-makefile.html help: @cat Makefile build/*.mk | grep -v '\.PHONY' | grep -v '\help:' | grep -B1 -E '^[a-zA-Z0-9_.-]+:.*' | sed -e "s/:.*//" | sed -e "s/^## //" | grep -v '\-\-' | sed '1!G;h;$$!d' | awk 'NR%2{printf "\033[36m%-30s\033[0m",$$0;next;}1' | sort diff --git a/server/plugin/api.go b/server/plugin/api.go index 9eb777e..bd77eb1 100644 --- a/server/plugin/api.go +++ b/server/plugin/api.go @@ -165,15 +165,9 @@ func (p *Plugin) checkAuth(handler http.HandlerFunc, responseType ResponseType) } func (p *Plugin) connectUserToGoogle(c *Context, w http.ResponseWriter, r *http.Request) { - userID := r.Header.Get("Mattermost-User-ID") - if userID == "" { - http.Error(w, "Not authorized", http.StatusUnauthorized) - return - } - conf := p.getOAuthConfig() - state := fmt.Sprintf("%v_%v", mattermostModel.NewId()[0:15], userID) + state := fmt.Sprintf("%v_%v", mattermostModel.NewId()[0:15], c.UserID) if err := p.KVStore.StoreOAuthStateToken(state, state); err != nil { c.Log.WithError(err).Warnf("Can't store state oauth2") @@ -183,7 +177,7 @@ func (p *Plugin) connectUserToGoogle(c *Context, w http.ResponseWriter, r *http. url := conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("prompt", "consent")) - ch := p.oauthBroker.SubscribeOAuthComplete(userID) + ch := p.oauthBroker.SubscribeOAuthComplete(c.UserID) go func() { ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) @@ -200,7 +194,7 @@ func (p *Plugin) connectUserToGoogle(c *Context, w http.ResponseWriter, r *http. } if errorMsg != "" { - _, err := p.poster.DMWithAttachments(userID, &mattermostModel.SlackAttachment{ + _, err := p.poster.DMWithAttachments(c.UserID, &mattermostModel.SlackAttachment{ Text: fmt.Sprintf("There was an error connecting to your Google account: `%s` Please double check your configuration.", errorMsg), Color: string(flow.ColorDanger), }) @@ -209,22 +203,16 @@ func (p *Plugin) connectUserToGoogle(c *Context, w http.ResponseWriter, r *http. } } - p.oauthBroker.UnsubscribeOAuthComplete(userID, ch) + p.oauthBroker.UnsubscribeOAuthComplete(c.UserID, ch) }() http.Redirect(w, r, url, http.StatusFound) } func (p *Plugin) completeConnectUserToGoogle(c *Context, w http.ResponseWriter, r *http.Request) { - authedUserID := r.Header.Get("Mattermost-User-ID") - if authedUserID == "" { - http.Error(w, "Not authorized", http.StatusUnauthorized) - return - } - var rErr error defer func() { - p.oauthBroker.publishOAuthComplete(authedUserID, rErr, false) + p.oauthBroker.publishOAuthComplete(c.UserID, rErr, false) }() config := p.getConfiguration() @@ -265,7 +253,7 @@ func (p *Plugin) completeConnectUserToGoogle(c *Context, w http.ResponseWriter, userID := strings.Split(state, "_")[1] - if userID != authedUserID { + if userID != c.UserID { rErr = errors.New("not authorized, incorrect user") http.Error(w, rErr.Error(), http.StatusUnauthorized) return @@ -704,8 +692,19 @@ func (p *Plugin) openCommentReplyDialog(c *Context, w http.ResponseWriter, r *ht return } - commentID := request.Context["commentID"].(string) - fileID := request.Context["fileID"].(string) + commentID, ok := request.Context["commentID"].(string) + if !ok { + p.API.LogError("Comment ID not found in the request") + w.WriteHeader(http.StatusBadRequest) + return + } + fileID, ok := request.Context["fileID"].(string) + if !ok { + p.API.LogError("File ID not found in the request") + w.WriteHeader(http.StatusBadRequest) + return + } + dialog := mattermostModel.OpenDialogRequest{ TriggerId: request.TriggerId, URL: fmt.Sprintf("%s/plugins/%s/api/v1/reply?fileID=%s&commentID=%s", *p.API.GetConfig().ServiceSettings.SiteURL, Manifest.Id, fileID, commentID), @@ -753,6 +752,13 @@ func (p *Plugin) handleCommentReplyDialog(c *Context, w http.ResponseWriter, r * commentID := r.URL.Query().Get("commentID") fileID := r.URL.Query().Get("fileID") + message, ok := request.Submission["message"].(string) + if !ok { + p.API.LogError("Message not found in the request") + p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusBadRequest}) + return + } + driveService, err := p.GoogleClient.NewDriveService(c.Ctx, c.UserID) if err != nil { p.API.LogError("Failed to create Google Drive service", "err", err, "userID", c.UserID) @@ -760,7 +766,7 @@ func (p *Plugin) handleCommentReplyDialog(c *Context, w http.ResponseWriter, r * return } reply, err := driveService.CreateReply(c.Ctx, fileID, commentID, &drive.Reply{ - Content: request.Submission["message"].(string), + Content: message, }) if err != nil { p.API.LogError("Failed to create comment reply", "err", err) @@ -792,24 +798,30 @@ func (p *Plugin) handleFileUpload(c *Context, w http.ResponseWriter, r *http.Req } defer r.Body.Close() - fileID := request.Submission["fileID"].(string) + fileID, ok := request.Submission["fileID"].(string) + if !ok || fileID == "" { + c.Log.Errorf("File ID not found in the request") + p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusBadRequest}) + return + } + fileInfo, appErr := p.API.GetFileInfo(fileID) if appErr != nil { - p.API.LogError("Unable to fetch file info", "err", appErr, "fileID", fileID) + c.Log.WithError(appErr).Errorf("Unable to fetch file info", "fileID", fileID) p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusInternalServerError}) return } fileReader, appErr := p.API.GetFile(fileID) if appErr != nil { - p.API.LogError("Unable to fetch file data", "err", appErr, "fileID", fileID) + c.Log.WithError(appErr).Errorf("Unable to fetch file data", "fileID", fileID) p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusInternalServerError}) return } driveService, err := p.GoogleClient.NewDriveService(c.Ctx, c.UserID) if err != nil { - p.API.LogError("Failed to create Google Drive service", "err", err, "userID", c.UserID) + c.Log.WithError(err).Errorf("Failed to create Google Drive service") p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusInternalServerError}) return } @@ -818,7 +830,7 @@ func (p *Plugin) handleFileUpload(c *Context, w http.ResponseWriter, r *http.Req Name: fileInfo.Name, }, fileReader) if err != nil { - p.API.LogError("Failed to upload file", "err", err) + c.Log.WithError(err).Errorf("Failed to upload file") p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusInternalServerError}) return } @@ -834,7 +846,7 @@ func (p *Plugin) handleAllFilesUpload(c *Context, w http.ResponseWriter, r *http var request mattermostModel.SubmitDialogRequest err := json.NewDecoder(r.Body).Decode(&request) if err != nil { - p.API.LogError("Failed to decode SubmitDialogRequest", "err", err) + c.Log.WithError(err).Errorf("Failed to decode SubmitDialogRequest") p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusBadRequest}) return } @@ -843,14 +855,14 @@ func (p *Plugin) handleAllFilesUpload(c *Context, w http.ResponseWriter, r *http postID := request.State post, appErr := p.API.GetPost(postID) if appErr != nil { - p.API.LogError("Failed to get post", "err", appErr, "postID", postID) - p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusInternalServerError}) + c.Log.WithError(appErr).Errorf("Failed to get post", "postID", postID) + p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusBadRequest}) return } driveService, err := p.GoogleClient.NewDriveService(c.Ctx, c.UserID) if err != nil { - p.API.LogError("Failed to create Google Drive service", "err", err, "userID", c.UserID) + c.Log.WithError(err).Errorf("Failed to create Google Drive service") p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusInternalServerError}) return } @@ -859,14 +871,14 @@ func (p *Plugin) handleAllFilesUpload(c *Context, w http.ResponseWriter, r *http for _, fileID := range fileIDs { fileInfo, appErr := p.API.GetFileInfo(fileID) if appErr != nil { - p.API.LogError("Unable to get file info", "err", appErr, "fileID", fileID) + c.Log.WithError(appErr).Errorf("Unable to get file info", "fileID", fileID) p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusInternalServerError}) return } fileReader, appErr := p.API.GetFile(fileID) if appErr != nil { - p.API.LogError("Unable to get file", "err", appErr, "fileID", fileID) + c.Log.WithError(appErr).Errorf("Unable to get file", "fileID", fileID) p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusInternalServerError}) return } @@ -875,7 +887,7 @@ func (p *Plugin) handleAllFilesUpload(c *Context, w http.ResponseWriter, r *http Name: fileInfo.Name, }, fileReader) if err != nil { - p.API.LogError("Failed to upload file", "err", err) + c.Log.WithError(err).Errorf("Failed to upload file") p.writeInteractiveDialogError(w, DialogErrorResponse{StatusCode: http.StatusInternalServerError}) return } diff --git a/server/plugin/api_test.go b/server/plugin/api_test.go index 69db209..5bd9c7b 100644 --- a/server/plugin/api_test.go +++ b/server/plugin/api_test.go @@ -644,3 +644,161 @@ func TestFileCreationEndpoint(t *testing.T) { }) } } + +func TestUploadFile(t *testing.T) { + mockKvStore, mockGoogleClient, mockGoogleDrive, _, _, _, _, _, _ := GetMockSetup(t) + + for name, test := range map[string]struct { + expectedStatusCode int + submission *mattermostModel.SubmitDialogRequest + envSetup func(ctx context.Context, te *TestEnvironment) + }{ + "No file provided": { + expectedStatusCode: http.StatusBadRequest, + submission: &mattermostModel.SubmitDialogRequest{ + Submission: map[string]any{}, + }, + envSetup: func(ctx context.Context, te *TestEnvironment) { + te.mockAPI.On("LogError", mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string")) + }, + }, + "Empty fileID in submission": { + expectedStatusCode: http.StatusBadRequest, + submission: &mattermostModel.SubmitDialogRequest{ + Submission: map[string]any{ + "fileID": "", + }, + }, + envSetup: func(ctx context.Context, te *TestEnvironment) { + te.mockAPI.On("LogError", mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string")) + }, + }, + "Create File on Google Drive send ephemeral post": { + expectedStatusCode: http.StatusOK, + submission: &mattermostModel.SubmitDialogRequest{ + Submission: map[string]any{ + "fileID": "fileId1", + }, + }, + envSetup: func(ctx context.Context, te *TestEnvironment) { + te.mockAPI.On("GetFileInfo", "fileId1").Return(&mattermostModel.FileInfo{ + Id: "fileId1", + PostId: "postId1", + Name: "file name", + }, nil) + te.mockAPI.On("GetFile", "fileId1").Return([]byte{}, nil) + mockGoogleClient.EXPECT().NewDriveService(ctx, "userId1").Return(mockGoogleDrive, nil) + mockGoogleDrive.EXPECT().CreateFile(ctx, &drive.File{Name: "file name"}, []byte{}).Return(nil, nil) + te.mockAPI.On("SendEphemeralPost", "userId1", mock.Anything).Return(nil) + }, + }, + } { + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + te := SetupTestEnvironment(t) + defer te.Cleanup(t) + + te.plugin.KVStore = mockKvStore + te.plugin.GoogleClient = mockGoogleClient + te.plugin.initializeAPI() + + w := httptest.NewRecorder() + + var body bytes.Buffer + err := json.NewEncoder(&body).Encode(test.submission) + if err != nil { + require.NoError(t, err) + } + r := httptest.NewRequest(http.MethodPost, "/upload", &body) + r.Header.Set("Mattermost-User-ID", "userId1") + ctx, _ := te.plugin.createContext(w, r) + + test.envSetup(ctx.Ctx, te) + te.plugin.handleFileUpload(ctx, w, r) + + result := w.Result() + require.NotNil(t, result) + defer result.Body.Close() + assert.Equal(test.expectedStatusCode, result.StatusCode) + }) + } +} + +func TestUploadMultipleFiles(t *testing.T) { + mockKvStore, mockGoogleClient, mockGoogleDrive, _, _, _, _, _, _ := GetMockSetup(t) + + for name, test := range map[string]struct { + expectedStatusCode int + submission *mattermostModel.SubmitDialogRequest + envSetup func(ctx context.Context, te *TestEnvironment) + }{ + "No postId provided": { + expectedStatusCode: http.StatusBadRequest, + submission: &mattermostModel.SubmitDialogRequest{ + State: "", + }, + envSetup: func(ctx context.Context, te *TestEnvironment) { + te.mockAPI.On("GetPost", "").Return(nil, &mattermostModel.AppError{ + Message: "No post provided", + }) + te.mockAPI.On("LogError", mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.Anything, mock.Anything) + }, + }, + "PostId with multiple file attachments": { + expectedStatusCode: http.StatusOK, + submission: &mattermostModel.SubmitDialogRequest{ + State: "postId1", + }, + envSetup: func(ctx context.Context, te *TestEnvironment) { + te.mockAPI.On("GetPost", "postId1").Return(&mattermostModel.Post{ + Id: "postId1", + FileIds: []string{"fileId1", "fileId2"}, + }, nil) + mockGoogleClient.EXPECT().NewDriveService(ctx, "userId1").Return(mockGoogleDrive, nil) + te.mockAPI.On("GetFileInfo", "fileId1").Return(&mattermostModel.FileInfo{ + Id: "fileId1", + PostId: "postId1", + Name: "file name", + }, nil) + te.mockAPI.On("GetFile", "fileId1").Return([]byte{}, nil) + te.mockAPI.On("GetFileInfo", "fileId2").Return(&mattermostModel.FileInfo{ + Id: "fileId1", + PostId: "postId1", + Name: "file name", + }, nil) + te.mockAPI.On("GetFile", "fileId2").Return([]byte{}, nil) + mockGoogleDrive.EXPECT().CreateFile(ctx, &drive.File{Name: "file name"}, []byte{}).Return(nil, nil).Times(2) + te.mockAPI.On("SendEphemeralPost", "userId1", mock.Anything).Return(nil) + }, + }, + } { + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + te := SetupTestEnvironment(t) + defer te.Cleanup(t) + + te.plugin.KVStore = mockKvStore + te.plugin.GoogleClient = mockGoogleClient + te.plugin.initializeAPI() + + w := httptest.NewRecorder() + + var body bytes.Buffer + err := json.NewEncoder(&body).Encode(test.submission) + if err != nil { + require.NoError(t, err) + } + r := httptest.NewRequest(http.MethodPost, "/upload", &body) + r.Header.Set("Mattermost-User-ID", "userId1") + ctx, _ := te.plugin.createContext(w, r) + + test.envSetup(ctx.Ctx, te) + te.plugin.handleAllFilesUpload(ctx, w, r) + + result := w.Result() + require.NotNil(t, result) + defer result.Body.Close() + assert.Equal(test.expectedStatusCode, result.StatusCode) + }) + } +} diff --git a/server/plugin/kvstore/google.go b/server/plugin/kvstore/google.go index e90bbea..fcc4997 100644 --- a/server/plugin/kvstore/google.go +++ b/server/plugin/kvstore/google.go @@ -51,6 +51,16 @@ func (kv Impl) GetWatchChannelData(userID string) (*model.WatchChannelData, erro return &watchChannelData, nil } +func (kv Impl) GetWatchChannelDataUsingKey(key string) (*model.WatchChannelData, error) { + var watchChannelData model.WatchChannelData + + err := kv.client.KV.Get(key, &watchChannelData) + if err != nil { + return nil, errors.Wrap(err, "failed to get watch channel data") + } + return &watchChannelData, nil +} + func (kv Impl) ListWatchChannelDataKeys(page, perPage int) ([]string, error) { watchChannelKey := getWatchChannelDataKey("") keys, err := kv.client.KV.ListKeys(page, perPage, pluginapi.WithPrefix(watchChannelKey)) diff --git a/server/plugin/kvstore/kvstore.go b/server/plugin/kvstore/kvstore.go index 5742c36..354bd77 100644 --- a/server/plugin/kvstore/kvstore.go +++ b/server/plugin/kvstore/kvstore.go @@ -5,6 +5,7 @@ import "github.com/mattermost-community/mattermost-plugin-google-drive/server/pl type KVStore interface { StoreWatchChannelData(userID string, watchChannelData model.WatchChannelData) error GetWatchChannelData(userID string) (*model.WatchChannelData, error) + GetWatchChannelDataUsingKey(key string) (*model.WatchChannelData, error) ListWatchChannelDataKeys(page, perPage int) ([]string, error) DeleteWatchChannelData(userID string) error diff --git a/server/plugin/kvstore/mocks/mock_kvstore.go b/server/plugin/kvstore/mocks/mock_kvstore.go index 58097eb..0d3b285 100644 --- a/server/plugin/kvstore/mocks/mock_kvstore.go +++ b/server/plugin/kvstore/mocks/mock_kvstore.go @@ -166,6 +166,21 @@ func (mr *MockKVStoreMockRecorder) GetWatchChannelData(arg0 interface{}) *gomock return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWatchChannelData", reflect.TypeOf((*MockKVStore)(nil).GetWatchChannelData), arg0) } +// GetWatchChannelDataUsingKey mocks base method. +func (m *MockKVStore) GetWatchChannelDataUsingKey(arg0 string) (*model.WatchChannelData, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetWatchChannelDataUsingKey", arg0) + ret0, _ := ret[0].(*model.WatchChannelData) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetWatchChannelDataUsingKey indicates an expected call of GetWatchChannelDataUsingKey. +func (mr *MockKVStoreMockRecorder) GetWatchChannelDataUsingKey(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWatchChannelDataUsingKey", reflect.TypeOf((*MockKVStore)(nil).GetWatchChannelDataUsingKey), arg0) +} + // ListWatchChannelDataKeys mocks base method. func (m *MockKVStore) ListWatchChannelDataKeys(arg0, arg1 int) ([]string, error) { m.ctrl.T.Helper() diff --git a/server/plugin/notifications.go b/server/plugin/notifications.go index 6c9ce21..999a98a 100644 --- a/server/plugin/notifications.go +++ b/server/plugin/notifications.go @@ -314,7 +314,7 @@ func (p *Plugin) stopDriveActivityNotifications(userID string) string { err = p.KVStore.DeleteWatchChannelData(userID) if err != nil { - p.API.LogError("Failed to delete Google Drive watch channel data", "err", err) + p.API.LogError("Failed to delete Google Drive watch channel data", "err", err, "userID", userID) return "Something went wrong while stopping Google Drive activity notifications. Please contact your organization admin for support." } @@ -323,7 +323,7 @@ func (p *Plugin) stopDriveActivityNotifications(userID string) string { ResourceId: watchChannelData.ResourceID, }) if err != nil { - p.API.LogError("Failed to stop Google Drive change channel", "err", err) + p.API.LogError("Failed to stop Google Drive change channel", "err", err, "userID", userID) return "Something went wrong while stopping Google Drive activity notifications. Please contact your organization admin for support." } diff --git a/server/plugin/plugin.go b/server/plugin/plugin.go index 28b726a..1c02631 100644 --- a/server/plugin/plugin.go +++ b/server/plugin/plugin.go @@ -92,8 +92,8 @@ func (p *Plugin) refreshDriveWatchChannels() { worker := func(channels <-chan model.WatchChannelData, wg *sync.WaitGroup) { defer wg.Done() for channel := range channels { - _ = p.startDriveWatchChannel(channel.MMUserID) p.stopDriveActivityNotifications(channel.MMUserID) + _ = p.startDriveWatchChannel(channel.MMUserID) } } @@ -116,13 +116,13 @@ func (p *Plugin) refreshDriveWatchChannels() { } for _, key := range keys { - var watchChannelData model.WatchChannelData - err = p.Client.KV.Get(key, &watchChannelData) + var watchChannelData *model.WatchChannelData + watchChannelData, err := p.KVStore.GetWatchChannelDataUsingKey(key) if err != nil { continue } if time.Until(time.Unix(watchChannelData.Expiration, 0)) < 24*time.Hour { - channels <- watchChannelData + channels <- *watchChannelData } } diff --git a/server/plugin/plugin_test.go b/server/plugin/plugin_test.go new file mode 100644 index 0000000..fca4c2e --- /dev/null +++ b/server/plugin/plugin_test.go @@ -0,0 +1,93 @@ +package plugin + +import ( + "context" + "testing" + "time" + + "github.com/golang/mock/gomock" + mock_google "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/google/mocks" + mock_store "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/kvstore/mocks" + "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/model" + mattermostModel "github.com/mattermost/mattermost/server/public/model" + "google.golang.org/api/drive/v3" +) + +func TestRefreshDriveWatchChannels(t *testing.T) { + te := SetupTestEnvironment(t) + defer te.Cleanup(t) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockKVStore := mock_store.NewMockKVStore(ctrl) + mockGoogleClient := mock_google.NewMockClientInterface(ctrl) + mockGoogleDrive := mock_google.NewMockDriveInterface(ctrl) + + p := &Plugin{ + KVStore: mockKVStore, + Client: te.plugin.Client, + GoogleClient: mockGoogleClient, + } + + t.Run("processes channels correctly", func(t *testing.T) { + channel1 := &model.WatchChannelData{MMUserID: "userId1", Expiration: time.Now().Add(23 * time.Hour).Unix(), ChannelID: "channel1", ResourceID: "resource1"} + channel2 := &model.WatchChannelData{MMUserID: "userId2", Expiration: time.Now().Add(23 * time.Hour).Unix(), ChannelID: "channel2", ResourceID: "resource2"} + siteUrl := "http://localhost" + te.mockAPI.On("GetConfig").Return(&mattermostModel.Config{ServiceSettings: mattermostModel.ServiceSettings{SiteURL: &siteUrl}}) + + mockKVStore.EXPECT().ListWatchChannelDataKeys(gomock.Any(), gomock.Any()).Return([]string{"key1", "key2"}, nil).Times(1) + mockKVStore.EXPECT().ListWatchChannelDataKeys(gomock.Any(), gomock.Any()).Return([]string{}, nil).Times(1) + + mockKVStore.EXPECT().GetWatchChannelDataUsingKey("key1").Return(channel1, nil).Times(1) + mockKVStore.EXPECT().GetWatchChannelDataUsingKey("key2").Return(channel2, nil).Times(1) + + mockKVStore.EXPECT().GetWatchChannelData("userId1").Return(channel1, nil).Times(1) + mockKVStore.EXPECT().GetWatchChannelData("userId2").Return(channel2, nil).Times(1) + mockGoogleClient.EXPECT().NewDriveService(context.Background(), "userId1").Return(mockGoogleDrive, nil).Times(2) + mockGoogleClient.EXPECT().NewDriveService(context.Background(), "userId2").Return(mockGoogleDrive, nil).Times(2) + mockKVStore.EXPECT().DeleteWatchChannelData("userId1").Return(nil).Times(1) + mockKVStore.EXPECT().DeleteWatchChannelData("userId2").Return(nil).Times(1) + mockGoogleDrive.EXPECT().StopChannel(context.Background(), &drive.Channel{ + Id: channel1.ChannelID, + ResourceId: channel1.ResourceID, + }) + mockGoogleDrive.EXPECT().StopChannel(context.Background(), &drive.Channel{ + Id: channel2.ChannelID, + ResourceId: channel2.ResourceID, + }) + ctx := context.Background() + startPageToken1 := &drive.StartPageToken{ + StartPageToken: "newPageToken1", + } + startPageToken2 := &drive.StartPageToken{ + StartPageToken: "newPageToken2", + } + mockGoogleDrive.EXPECT().GetStartPageToken(ctx).Return(startPageToken1, nil).Times(1) + mockGoogleDrive.EXPECT().GetStartPageToken(ctx).Return(startPageToken2, nil).Times(1) + + channel1Data := model.WatchChannelData{ + ChannelID: "channel1Id", + ResourceID: channel1.ResourceID, + Expiration: channel1.Expiration, + Token: channel1.Token, + MMUserID: "userId1", + PageToken: startPageToken1.StartPageToken, + } + channel2Data := model.WatchChannelData{ + ChannelID: "channel2Id", + ResourceID: channel2.ResourceID, + Expiration: channel2.Expiration, + Token: channel2.Token, + MMUserID: "userId2", + PageToken: startPageToken2.StartPageToken, + } + + mockGoogleDrive.EXPECT().WatchChannel(ctx, startPageToken1, gomock.Any()).Return(&drive.Channel{Id: "channel1Id", ResourceId: channel1.ResourceID, Expiration: channel1.Expiration, Token: channel1.Token}, nil).Times(1) + mockGoogleDrive.EXPECT().WatchChannel(ctx, startPageToken2, gomock.Any()).Return(&drive.Channel{Id: "channel2Id", ResourceId: channel2.ResourceID, Expiration: channel2.Expiration, Token: channel2.Token}, nil).Times(1) + mockKVStore.EXPECT().StoreWatchChannelData("userId1", channel1Data).Return(nil).Times(1) + mockKVStore.EXPECT().StoreWatchChannelData("userId2", channel2Data).Return(nil).Times(1) + + p.refreshDriveWatchChannels() + }) +} From 0fdb51a0a134b75396067ed8c401484ed2de3003 Mon Sep 17 00:00:00 2001 From: Benjamin Cooke Date: Mon, 4 Nov 2024 18:41:45 -0500 Subject: [PATCH 14/17] lint --- server/plugin/api_test.go | 12 ++++++------ server/plugin/plugin_test.go | 9 +++++---- server/plugin/test_utils.go | 13 +++++++------ 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/server/plugin/api_test.go b/server/plugin/api_test.go index 5bd9c7b..aa13b6a 100644 --- a/server/plugin/api_test.go +++ b/server/plugin/api_test.go @@ -295,11 +295,11 @@ func TestNotificationWebhook(t *testing.T) { ItemName: fmt.Sprintf("items/%s", file.Id), Filter: "time > \"" + file.ModifiedTime + "\"", }).Return(activityResponse, nil).MaxTimes(1) - commentId := activityResponse.Activities[0].Targets[0].FileComment.LegacyCommentId - comment := GetSampleComment(commentId) - mockGoogleDrive.EXPECT().GetComments(context.Background(), file.Id, commentId).Return(comment, nil) - siteUrl := "http://localhost" - te.mockAPI.On("GetConfig").Return(&mattermostModel.Config{ServiceSettings: mattermostModel.ServiceSettings{SiteURL: &siteUrl}}) + commentID := activityResponse.Activities[0].Targets[0].FileComment.LegacyCommentId + comment := GetSampleComment(commentID) + mockGoogleDrive.EXPECT().GetComments(context.Background(), file.Id, commentID).Return(comment, nil) + siteURL := "http://localhost" + te.mockAPI.On("GetConfig").Return(&mattermostModel.Config{ServiceSettings: mattermostModel.ServiceSettings{SiteURL: &siteURL}}) te.mockAPI.On("GetDirectChannel", "userId1", te.plugin.BotUserID).Return(&mattermostModel.Channel{Id: "channelId1"}, nil).Times(1) post := &mattermostModel.Post{ UserId: te.plugin.BotUserID, @@ -314,7 +314,7 @@ func TestNotificationWebhook(t *testing.T) { map[string]any{ "name": "Reply to comment", "integration": map[string]any{ - "url": fmt.Sprintf("%s/plugins/%s/api/v1/reply_dialog", siteUrl, Manifest.Id), + "url": fmt.Sprintf("%s/plugins/%s/api/v1/reply_dialog", siteURL, Manifest.Id), "context": map[string]any{ "commentID": comment.Id, "fileID": file.Id, diff --git a/server/plugin/plugin_test.go b/server/plugin/plugin_test.go index fca4c2e..a58ddd1 100644 --- a/server/plugin/plugin_test.go +++ b/server/plugin/plugin_test.go @@ -6,11 +6,12 @@ import ( "time" "github.com/golang/mock/gomock" + mattermostModel "github.com/mattermost/mattermost/server/public/model" + "google.golang.org/api/drive/v3" + mock_google "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/google/mocks" mock_store "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/kvstore/mocks" "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/model" - mattermostModel "github.com/mattermost/mattermost/server/public/model" - "google.golang.org/api/drive/v3" ) func TestRefreshDriveWatchChannels(t *testing.T) { @@ -33,8 +34,8 @@ func TestRefreshDriveWatchChannels(t *testing.T) { t.Run("processes channels correctly", func(t *testing.T) { channel1 := &model.WatchChannelData{MMUserID: "userId1", Expiration: time.Now().Add(23 * time.Hour).Unix(), ChannelID: "channel1", ResourceID: "resource1"} channel2 := &model.WatchChannelData{MMUserID: "userId2", Expiration: time.Now().Add(23 * time.Hour).Unix(), ChannelID: "channel2", ResourceID: "resource2"} - siteUrl := "http://localhost" - te.mockAPI.On("GetConfig").Return(&mattermostModel.Config{ServiceSettings: mattermostModel.ServiceSettings{SiteURL: &siteUrl}}) + siteURL := "http://localhost" + te.mockAPI.On("GetConfig").Return(&mattermostModel.Config{ServiceSettings: mattermostModel.ServiceSettings{SiteURL: &siteURL}}) mockKVStore.EXPECT().ListWatchChannelDataKeys(gomock.Any(), gomock.Any()).Return([]string{"key1", "key2"}, nil).Times(1) mockKVStore.EXPECT().ListWatchChannelDataKeys(gomock.Any(), gomock.Any()).Return([]string{}, nil).Times(1) diff --git a/server/plugin/test_utils.go b/server/plugin/test_utils.go index 72a2443..0a14ce3 100644 --- a/server/plugin/test_utils.go +++ b/server/plugin/test_utils.go @@ -4,8 +4,6 @@ import ( "testing" "github.com/golang/mock/gomock" - "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/model" - mock_pluginapi "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/pluginapi/mocks" "github.com/mattermost/mattermost/server/public/plugin/plugintest" "github.com/mattermost/mattermost/server/public/pluginapi" "google.golang.org/api/docs/v1" @@ -14,6 +12,9 @@ import ( "google.golang.org/api/sheets/v4" "google.golang.org/api/slides/v1" + "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/model" + mock_pluginapi "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/pluginapi/mocks" + mock_google "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/google/mocks" mock_store "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/kvstore/mocks" @@ -140,10 +141,10 @@ func GetSampleDriveactivityPermissionResponse() *driveactivity.QueryDriveActivit } } -func GetSampleComment(commentId string) *drive.Comment { +func GetSampleComment(commentID string) *drive.Comment { return &drive.Comment{ Content: "comment1", - Id: commentId, + Id: commentID, Author: &drive.User{ DisplayName: "author1", }, @@ -184,9 +185,9 @@ func GetSamplePresentation() *slides.Presentation { } } -func GetSampleFile(fileId string) *drive.File { +func GetSampleFile(fileID string) *drive.File { return &drive.File{ - Id: fileId, + Id: fileID, ViewedByMeTime: "2020-01-01T00:00:00.000Z", ModifiedTime: "2021-01-01T00:00:00.000Z", CreatedTime: "2021-01-01T00:00:00.000Z", From cb7bfec5f0990e1b28b1f50c78abb65ecdf6ea7d Mon Sep 17 00:00:00 2001 From: Benjamin Cooke Date: Wed, 6 Nov 2024 16:26:37 -0500 Subject: [PATCH 15/17] more api tests and creating more mocks... --- Makefile | 3 + server/plugin/api.go | 14 +-- server/plugin/api_test.go | 110 +++++++++++++++++- server/plugin/google/google.go | 11 +- server/plugin/oauth2/interfaces.go | 13 +++ server/plugin/oauth2/mocks/mock_oauth2.go | 79 +++++++++++++ server/plugin/oauth2/oauth2.go | 49 ++++++++ server/plugin/{oauth.go => oauthbroker.go} | 27 ----- server/plugin/plugin.go | 6 +- server/plugin/plugin_test.go | 26 +---- .../plugin/pluginapi/mocks/mock_telemetry.go | 75 ++++++++++++ server/plugin/test_utils.go | 23 +++- 12 files changed, 364 insertions(+), 72 deletions(-) create mode 100644 server/plugin/oauth2/interfaces.go create mode 100644 server/plugin/oauth2/mocks/mock_oauth2.go create mode 100644 server/plugin/oauth2/oauth2.go rename server/plugin/{oauth.go => oauthbroker.go} (66%) create mode 100644 server/plugin/pluginapi/mocks/mock_telemetry.go diff --git a/Makefile b/Makefile index 4ddc0cd..685206b 100644 --- a/Makefile +++ b/Makefile @@ -294,6 +294,7 @@ logs-watch: ## Generate mocks mock: ifneq ($(HAS_SERVER),) + go install github.com/golang/mock/mockgen@v1.6.0 mockgen -destination=server/plugin/google/mocks/mock_drive_activity.go -package=mocks github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/google DriveActivityInterface mockgen -destination=server/plugin/google/mocks/mock_google.go -package=mocks github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/google ClientInterface mockgen -destination=server/plugin/pluginapi/mocks/mock_cluster_mutex.go -package=mocks github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/pluginapi ClusterMutex @@ -303,6 +304,8 @@ ifneq ($(HAS_SERVER),) mockgen -destination=server/plugin/google/mocks/mock_sheets.go -package=mocks github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/google SheetsInterface mockgen -destination=server/plugin/google/mocks/mock_docs.go -package=mocks github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/google DocsInterface mockgen -destination=server/plugin/google/mocks/mock_slides.go -package=mocks github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/google SlidesInterface + mockgen -destination=server/plugin/oauth2/mocks/mock_oauth2.go -package=mocks github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/oauth2 ConfigInterface + mockgen -destination=server/plugin/pluginapi/mocks/mock_telemetry.go -package=mocks github.com/mattermost/mattermost/server/public/pluginapi/experimental/telemetry Tracker endif # Help documentation à la https://marmelab.com/blog/2016/02/29/auto-documented-makefile.html diff --git a/server/plugin/api.go b/server/plugin/api.go index 77c846f..0aa108f 100644 --- a/server/plugin/api.go +++ b/server/plugin/api.go @@ -16,7 +16,6 @@ import ( "github.com/mattermost/mattermost/server/public/pluginapi/experimental/bot/logger" "github.com/mattermost/mattermost/server/public/pluginapi/experimental/flow" "github.com/pkg/errors" - "golang.org/x/oauth2" "google.golang.org/api/docs/v1" "google.golang.org/api/drive/v3" "google.golang.org/api/driveactivity/v2" @@ -165,8 +164,6 @@ func (p *Plugin) checkAuth(handler http.HandlerFunc, responseType ResponseType) } func (p *Plugin) connectUserToGoogle(c *Context, w http.ResponseWriter, r *http.Request) { - conf := p.getOAuthConfig() - state := fmt.Sprintf("%v_%v", mattermostModel.NewId()[0:15], c.UserID) if err := p.KVStore.StoreOAuthStateToken(state, state); err != nil { @@ -175,7 +172,7 @@ func (p *Plugin) connectUserToGoogle(c *Context, w http.ResponseWriter, r *http. return } - url := conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("prompt", "consent")) + url := p.oauthConfig.AuthCodeURL(state) ch := p.oauthBroker.SubscribeOAuthComplete(c.UserID) @@ -217,8 +214,6 @@ func (p *Plugin) completeConnectUserToGoogle(c *Context, w http.ResponseWriter, config := p.getConfiguration() - conf := p.getOAuthConfig() - code := r.URL.Query().Get("code") if len(code) == 0 { rErr = errors.New("missing authorization code") @@ -227,6 +222,11 @@ func (p *Plugin) completeConnectUserToGoogle(c *Context, w http.ResponseWriter, } state := r.URL.Query().Get("state") + if len(state) == 0 { + rErr = errors.New("missing state") + http.Error(w, rErr.Error(), http.StatusBadRequest) + return + } storedState, err := p.KVStore.GetOAuthStateToken(state) if err != nil { @@ -259,7 +259,7 @@ func (p *Plugin) completeConnectUserToGoogle(c *Context, w http.ResponseWriter, return } - token, err := conf.Exchange(c.Ctx, code) + token, err := p.oauthConfig.Exchange(c.Ctx, code) if err != nil { c.Log.WithError(err).Warnf("Can't exchange state") diff --git a/server/plugin/api_test.go b/server/plugin/api_test.go index aa13b6a..98d59e2 100644 --- a/server/plugin/api_test.go +++ b/server/plugin/api_test.go @@ -10,10 +10,13 @@ import ( "testing" "time" + "github.com/golang/mock/gomock" mattermostModel "github.com/mattermost/mattermost/server/public/model" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "golang.org/x/oauth2" "google.golang.org/api/docs/v1" "google.golang.org/api/drive/v3" "google.golang.org/api/driveactivity/v2" @@ -26,7 +29,7 @@ import ( ) func TestNotificationWebhook(t *testing.T) { - mockKvStore, mockGoogleClient, mockGoogleDrive, mockDriveActivity, _, _, _, _, mockCluster := GetMockSetup(t) + mockKvStore, mockGoogleClient, mockGoogleDrive, mockDriveActivity, _, _, _, _, mockCluster, _, _ := GetMockSetup(t) for name, test := range map[string]struct { expectedStatusCode int @@ -365,7 +368,7 @@ func TestNotificationWebhook(t *testing.T) { } func TestFileCreationEndpoint(t *testing.T) { - mockKvStore, mockGoogleClient, mockGoogleDrive, _, mockGoogleDocs, mockGoogleSheets, mockGoogleSlides, _, _ := GetMockSetup(t) + mockKvStore, mockGoogleClient, mockGoogleDrive, _, mockGoogleDocs, mockGoogleSheets, mockGoogleSlides, _, _, _, _ := GetMockSetup(t) for name, test := range map[string]struct { expectedStatusCode int @@ -646,7 +649,7 @@ func TestFileCreationEndpoint(t *testing.T) { } func TestUploadFile(t *testing.T) { - mockKvStore, mockGoogleClient, mockGoogleDrive, _, _, _, _, _, _ := GetMockSetup(t) + mockKvStore, mockGoogleClient, mockGoogleDrive, _, _, _, _, _, _, _, _ := GetMockSetup(t) for name, test := range map[string]struct { expectedStatusCode int @@ -725,7 +728,7 @@ func TestUploadFile(t *testing.T) { } func TestUploadMultipleFiles(t *testing.T) { - mockKvStore, mockGoogleClient, mockGoogleDrive, _, _, _, _, _, _ := GetMockSetup(t) + mockKvStore, mockGoogleClient, mockGoogleDrive, _, _, _, _, _, _, _, _ := GetMockSetup(t) for name, test := range map[string]struct { expectedStatusCode int @@ -802,3 +805,102 @@ func TestUploadMultipleFiles(t *testing.T) { }) } } + +func TestCompleteConnectUserToGoogle(t *testing.T) { + mockKvStore, mockGoogleClient, _, _, _, _, _, _, _, mockOAuth2, mockTelemetry := GetMockSetup(t) + + for name, test := range map[string]struct { + expectedStatusCode int + envSetup func(ctx context.Context, te *TestEnvironment) + modifyRequest func(*http.Request) *http.Request + }{ + "No code in URL query": { + expectedStatusCode: http.StatusBadRequest, + envSetup: func(ctx context.Context, te *TestEnvironment) { + }, + modifyRequest: func(r *http.Request) *http.Request { + values := r.URL.Query() + values.Del("code") + r.URL.RawQuery = values.Encode() + return r + }, + }, + "No state in URL query": { + expectedStatusCode: http.StatusBadRequest, + envSetup: func(ctx context.Context, te *TestEnvironment) { + }, + modifyRequest: func(r *http.Request) *http.Request { + values := r.URL.Query() + values.Del("state") + r.URL.RawQuery = values.Encode() + return r + }, + }, + "State token does not match stored token": { + expectedStatusCode: http.StatusBadRequest, + envSetup: func(ctx context.Context, te *TestEnvironment) { + mockKvStore.EXPECT().GetOAuthStateToken("oauthstate_userId1").Return([]byte("randomState"), nil) + mockKvStore.EXPECT().DeleteOAuthStateToken("oauthstate_userId1").Return(nil) + }, + }, + "State token does not contain the correct userID": { + expectedStatusCode: http.StatusUnauthorized, + envSetup: func(ctx context.Context, te *TestEnvironment) { + mockKvStore.EXPECT().GetOAuthStateToken("oauthstate_userId123").Return([]byte("oauthstate_userId123"), nil) + mockKvStore.EXPECT().DeleteOAuthStateToken("oauthstate_userId123").Return(nil) + }, + modifyRequest: func(r *http.Request) *http.Request { + values := r.URL.Query() + values.Set("state", "oauthstate_userId123") + values.Set("code", "oauthcode") + r.URL.RawQuery = values.Encode() + return r + }, + }, + "Success complete oauth setup": { + expectedStatusCode: http.StatusOK, + envSetup: func(ctx context.Context, te *TestEnvironment) { + mockKvStore.EXPECT().GetOAuthStateToken("oauthstate_userId1").Return([]byte("oauthstate_userId1"), nil) + mockKvStore.EXPECT().DeleteOAuthStateToken("oauthstate_userId1").Return(nil) + mockOAuth2.EXPECT().Exchange(ctx, "oauthcode").Return(&oauth2.Token{ + AccessToken: "accessToken12345", + TokenType: "Bearer", + Expiry: time.Now().Add(time.Hour), + }, nil) + mockKvStore.EXPECT().StoreGoogleUserToken("userId1", gomock.Any()).Return(nil) + te.mockAPI.On("GetDirectChannel", "userId1", te.plugin.BotUserID).Return(&mattermostModel.Channel{Id: "channelId1"}, nil).Times(1) + te.mockAPI.On("CreatePost", mock.Anything).Return(nil, nil).Times(1) + mockTelemetry.EXPECT().TrackUserEvent("account_connected", "userId1", nil) + te.mockAPI.On("PublishWebSocketEvent", "google_connect", map[string]interface{}{"connected": true, "google_client_id": "randomstring.apps.googleusercontent.com"}, &mattermostModel.WebsocketBroadcast{OmitUsers: map[string]bool(nil), UserId: "userId1", ChannelId: "", TeamId: "", ConnectionId: "", OmitConnectionId: "", ContainsSanitizedData: false, ContainsSensitiveData: false, ReliableClusterSend: false, BroadcastHooks: []string(nil), BroadcastHookArgs: []map[string]interface{}(nil)}).Times(1) + }, + }, + } { + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + te := SetupTestEnvironment(t) + defer te.Cleanup(t) + + te.plugin.KVStore = mockKvStore + te.plugin.GoogleClient = mockGoogleClient + te.plugin.oauthConfig = mockOAuth2 + te.plugin.tracker = mockTelemetry + te.plugin.initializeAPI() + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/complete?code=oauthcode&state=oauthstate_userId1", nil) + r.Header.Set("Mattermost-User-ID", "userId1") + ctx, _ := te.plugin.createContext(w, r) + if test.modifyRequest != nil { + r = test.modifyRequest(r) + } + + test.envSetup(ctx.Ctx, te) + te.plugin.completeConnectUserToGoogle(ctx, w, r) + + result := w.Result() + require.NotNil(t, result) + defer result.Body.Close() + assert.Equal(test.expectedStatusCode, result.StatusCode) + }) + } +} diff --git a/server/plugin/google/google.go b/server/plugin/google/google.go index 1368cec..23ec26c 100644 --- a/server/plugin/google/google.go +++ b/server/plugin/google/google.go @@ -6,7 +6,7 @@ import ( "errors" "github.com/mattermost/mattermost/server/public/plugin" - "golang.org/x/oauth2" + oauth2package "golang.org/x/oauth2" "golang.org/x/time/rate" "google.golang.org/api/docs/v1" driveV2 "google.golang.org/api/drive/v2" @@ -20,11 +20,12 @@ import ( "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/config" "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/kvstore" "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/model" + "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/oauth2" "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/utils" ) type Client struct { - oauthConfig *oauth2.Config + oauthConfig oauth2.Config config *config.Configuration kvstore kvstore.KVStore papi plugin.API @@ -47,7 +48,7 @@ const ( driveActivityServiceType = "driveactivity" ) -func NewGoogleClient(oauthConfig *oauth2.Config, config *config.Configuration, kvstore kvstore.KVStore, papi plugin.API) ClientInterface { +func NewGoogleClient(oauthConfig oauth2.Config, config *config.Configuration, kvstore kvstore.KVStore, papi plugin.API) ClientInterface { maximumQueriesPerSecond := config.QueriesPerMinute / 60 burstSize := config.BurstSize @@ -238,7 +239,7 @@ func (g *Client) NewDriveActivityService(ctx context.Context, userID string) (Dr }, nil } -func (g *Client) GetGoogleUserToken(userID string) (*oauth2.Token, error) { +func (g *Client) GetGoogleUserToken(userID string) (*oauth2package.Token, error) { encryptedToken, err := g.kvstore.GetGoogleUserToken(userID) if err != nil { return nil, err @@ -253,7 +254,7 @@ func (g *Client) GetGoogleUserToken(userID string) (*oauth2.Token, error) { return nil, err } - var oauthToken oauth2.Token + var oauthToken oauth2package.Token err = json.Unmarshal([]byte(decryptedToken), &oauthToken) return &oauthToken, err diff --git a/server/plugin/oauth2/interfaces.go b/server/plugin/oauth2/interfaces.go new file mode 100644 index 0000000..d5f7e70 --- /dev/null +++ b/server/plugin/oauth2/interfaces.go @@ -0,0 +1,13 @@ +package oauth2 + +import ( + "context" + + oauth2package "golang.org/x/oauth2" +) + +type Config interface { + Exchange(ctx context.Context, code string) (*oauth2package.Token, error) + AuthCodeURL(state string) string + TokenSource(ctx context.Context, t *oauth2package.Token) oauth2package.TokenSource +} diff --git a/server/plugin/oauth2/mocks/mock_oauth2.go b/server/plugin/oauth2/mocks/mock_oauth2.go new file mode 100644 index 0000000..bed3660 --- /dev/null +++ b/server/plugin/oauth2/mocks/mock_oauth2.go @@ -0,0 +1,79 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/oauth2 (interfaces: Config) + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + oauth2 "golang.org/x/oauth2" +) + +// MockConfigInterface is a mock of Config interface. +type MockConfigInterface struct { + ctrl *gomock.Controller + recorder *MockConfigInterfaceMockRecorder +} + +// MockConfigInterfaceMockRecorder is the mock recorder for MockConfigInterface. +type MockConfigInterfaceMockRecorder struct { + mock *MockConfigInterface +} + +// NewMockConfigInterface creates a new mock instance. +func NewMockConfigInterface(ctrl *gomock.Controller) *MockConfigInterface { + mock := &MockConfigInterface{ctrl: ctrl} + mock.recorder = &MockConfigInterfaceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockConfigInterface) EXPECT() *MockConfigInterfaceMockRecorder { + return m.recorder +} + +// AuthCodeURL mocks base method. +func (m *MockConfigInterface) AuthCodeURL(arg0 string) string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AuthCodeURL", arg0) + ret0, _ := ret[0].(string) + return ret0 +} + +// AuthCodeURL indicates an expected call of AuthCodeURL. +func (mr *MockConfigInterfaceMockRecorder) AuthCodeURL(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AuthCodeURL", reflect.TypeOf((*MockConfigInterface)(nil).AuthCodeURL), arg0) +} + +// Exchange mocks base method. +func (m *MockConfigInterface) Exchange(arg0 context.Context, arg1 string) (*oauth2.Token, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Exchange", arg0, arg1) + ret0, _ := ret[0].(*oauth2.Token) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Exchange indicates an expected call of Exchange. +func (mr *MockConfigInterfaceMockRecorder) Exchange(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Exchange", reflect.TypeOf((*MockConfigInterface)(nil).Exchange), arg0, arg1) +} + +// TokenSource mocks base method. +func (m *MockConfigInterface) TokenSource(arg0 context.Context, arg1 *oauth2.Token) oauth2.TokenSource { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "TokenSource", arg0, arg1) + ret0, _ := ret[0].(oauth2.TokenSource) + return ret0 +} + +// TokenSource indicates an expected call of TokenSource. +func (mr *MockConfigInterfaceMockRecorder) TokenSource(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TokenSource", reflect.TypeOf((*MockConfigInterface)(nil).TokenSource), arg0, arg1) +} diff --git a/server/plugin/oauth2/oauth2.go b/server/plugin/oauth2/oauth2.go new file mode 100644 index 0000000..622446a --- /dev/null +++ b/server/plugin/oauth2/oauth2.go @@ -0,0 +1,49 @@ +package oauth2 + +import ( + "context" + "fmt" + + "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/config" + oauth2package "golang.org/x/oauth2" + "golang.org/x/oauth2/google" +) + +type ConfigWrapper struct { + oauth2Config *oauth2package.Config +} + +func GetOAuthConfig(config *config.Configuration, siteURL *string, manifestID string) Config { + scopes := []string{ + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/drive.file", + "https://www.googleapis.com/auth/drive.activity", + "https://www.googleapis.com/auth/documents", + "https://www.googleapis.com/auth/presentations", + "https://www.googleapis.com/auth/spreadsheets", + "https://www.googleapis.com/auth/drive", + } + + return &ConfigWrapper{ + oauth2Config: &oauth2package.Config{ + ClientID: config.GoogleOAuthClientID, + ClientSecret: config.GoogleOAuthClientSecret, + Scopes: scopes, + RedirectURL: fmt.Sprintf("%s/plugins/%s/oauth/complete", *siteURL, manifestID), + Endpoint: google.Endpoint, + }, + } +} + +func (oauth2 *ConfigWrapper) Exchange(ctx context.Context, code string) (*oauth2package.Token, error) { + return oauth2.oauth2Config.Exchange(ctx, code) +} + +func (oauth2 *ConfigWrapper) AuthCodeURL(state string) string { + return oauth2.oauth2Config.AuthCodeURL(state, oauth2package.AccessTypeOffline, oauth2package.SetAuthURLParam("prompt", "consent")) +} + +func (oauth2 *ConfigWrapper) TokenSource(ctx context.Context, t *oauth2package.Token) oauth2package.TokenSource { + return oauth2.oauth2Config.TokenSource(ctx, t) +} diff --git a/server/plugin/oauth.go b/server/plugin/oauthbroker.go similarity index 66% rename from server/plugin/oauth.go rename to server/plugin/oauthbroker.go index 7d8d0f5..4ce38cc 100644 --- a/server/plugin/oauth.go +++ b/server/plugin/oauthbroker.go @@ -1,11 +1,7 @@ package plugin import ( - "fmt" "sync" - - "golang.org/x/oauth2" - "golang.org/x/oauth2/google" ) type OAuthCompleteEvent struct { @@ -22,29 +18,6 @@ type OAuthBroker struct { mapCreate sync.Once } -func (p *Plugin) getOAuthConfig() *oauth2.Config { - config := p.getConfiguration() - - scopes := []string{ - "https://www.googleapis.com/auth/userinfo.profile", - "https://www.googleapis.com/auth/userinfo.email", - "https://www.googleapis.com/auth/drive.file", - "https://www.googleapis.com/auth/drive.activity", - "https://www.googleapis.com/auth/documents", - "https://www.googleapis.com/auth/presentations", - "https://www.googleapis.com/auth/spreadsheets", - "https://www.googleapis.com/auth/drive", - } - - return &oauth2.Config{ - ClientID: config.GoogleOAuthClientID, - ClientSecret: config.GoogleOAuthClientSecret, - Scopes: scopes, - RedirectURL: fmt.Sprintf("%s/plugins/%s/oauth/complete", *p.Client.Configuration.GetConfig().ServiceSettings.SiteURL, Manifest.Id), - Endpoint: google.Endpoint, - } -} - func (ob *OAuthBroker) publishOAuthComplete(userID string, err error, fromCluster bool) { ob.lock.Lock() defer ob.lock.Unlock() diff --git a/server/plugin/plugin.go b/server/plugin/plugin.go index 1c02631..30b1552 100644 --- a/server/plugin/plugin.go +++ b/server/plugin/plugin.go @@ -19,6 +19,7 @@ import ( "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/google" "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/kvstore" "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/model" + "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/oauth2" "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/utils" ) @@ -49,6 +50,7 @@ type Plugin struct { FlowManager *FlowManager oauthBroker *OAuthBroker + oauthConfig oauth2.Config channelRefreshJob *cluster.Job @@ -172,7 +174,9 @@ func (p *Plugin) OnActivate() error { return errors.Wrap(err, "failed to create a scheduled recurring job to refresh watch channels") } - p.GoogleClient = google.NewGoogleClient(p.getOAuthConfig(), p.getConfiguration(), p.KVStore, p.API) + p.oauthConfig = oauth2.GetOAuthConfig(p.getConfiguration(), siteURL, Manifest.Id) + + p.GoogleClient = google.NewGoogleClient(p.oauthConfig, p.getConfiguration(), p.KVStore, p.API) return nil } diff --git a/server/plugin/plugin_test.go b/server/plugin/plugin_test.go index a58ddd1..d5d2631 100644 --- a/server/plugin/plugin_test.go +++ b/server/plugin/plugin_test.go @@ -33,39 +33,27 @@ func TestRefreshDriveWatchChannels(t *testing.T) { t.Run("processes channels correctly", func(t *testing.T) { channel1 := &model.WatchChannelData{MMUserID: "userId1", Expiration: time.Now().Add(23 * time.Hour).Unix(), ChannelID: "channel1", ResourceID: "resource1"} - channel2 := &model.WatchChannelData{MMUserID: "userId2", Expiration: time.Now().Add(23 * time.Hour).Unix(), ChannelID: "channel2", ResourceID: "resource2"} siteURL := "http://localhost" te.mockAPI.On("GetConfig").Return(&mattermostModel.Config{ServiceSettings: mattermostModel.ServiceSettings{SiteURL: &siteURL}}) - mockKVStore.EXPECT().ListWatchChannelDataKeys(gomock.Any(), gomock.Any()).Return([]string{"key1", "key2"}, nil).Times(1) + mockKVStore.EXPECT().ListWatchChannelDataKeys(gomock.Any(), gomock.Any()).Return([]string{"key1"}, nil).Times(1) mockKVStore.EXPECT().ListWatchChannelDataKeys(gomock.Any(), gomock.Any()).Return([]string{}, nil).Times(1) mockKVStore.EXPECT().GetWatchChannelDataUsingKey("key1").Return(channel1, nil).Times(1) - mockKVStore.EXPECT().GetWatchChannelDataUsingKey("key2").Return(channel2, nil).Times(1) mockKVStore.EXPECT().GetWatchChannelData("userId1").Return(channel1, nil).Times(1) - mockKVStore.EXPECT().GetWatchChannelData("userId2").Return(channel2, nil).Times(1) mockGoogleClient.EXPECT().NewDriveService(context.Background(), "userId1").Return(mockGoogleDrive, nil).Times(2) - mockGoogleClient.EXPECT().NewDriveService(context.Background(), "userId2").Return(mockGoogleDrive, nil).Times(2) mockKVStore.EXPECT().DeleteWatchChannelData("userId1").Return(nil).Times(1) - mockKVStore.EXPECT().DeleteWatchChannelData("userId2").Return(nil).Times(1) mockGoogleDrive.EXPECT().StopChannel(context.Background(), &drive.Channel{ Id: channel1.ChannelID, ResourceId: channel1.ResourceID, }) - mockGoogleDrive.EXPECT().StopChannel(context.Background(), &drive.Channel{ - Id: channel2.ChannelID, - ResourceId: channel2.ResourceID, - }) ctx := context.Background() startPageToken1 := &drive.StartPageToken{ StartPageToken: "newPageToken1", } - startPageToken2 := &drive.StartPageToken{ - StartPageToken: "newPageToken2", - } + mockGoogleDrive.EXPECT().GetStartPageToken(ctx).Return(startPageToken1, nil).Times(1) - mockGoogleDrive.EXPECT().GetStartPageToken(ctx).Return(startPageToken2, nil).Times(1) channel1Data := model.WatchChannelData{ ChannelID: "channel1Id", @@ -75,19 +63,9 @@ func TestRefreshDriveWatchChannels(t *testing.T) { MMUserID: "userId1", PageToken: startPageToken1.StartPageToken, } - channel2Data := model.WatchChannelData{ - ChannelID: "channel2Id", - ResourceID: channel2.ResourceID, - Expiration: channel2.Expiration, - Token: channel2.Token, - MMUserID: "userId2", - PageToken: startPageToken2.StartPageToken, - } mockGoogleDrive.EXPECT().WatchChannel(ctx, startPageToken1, gomock.Any()).Return(&drive.Channel{Id: "channel1Id", ResourceId: channel1.ResourceID, Expiration: channel1.Expiration, Token: channel1.Token}, nil).Times(1) - mockGoogleDrive.EXPECT().WatchChannel(ctx, startPageToken2, gomock.Any()).Return(&drive.Channel{Id: "channel2Id", ResourceId: channel2.ResourceID, Expiration: channel2.Expiration, Token: channel2.Token}, nil).Times(1) mockKVStore.EXPECT().StoreWatchChannelData("userId1", channel1Data).Return(nil).Times(1) - mockKVStore.EXPECT().StoreWatchChannelData("userId2", channel2Data).Return(nil).Times(1) p.refreshDriveWatchChannels() }) diff --git a/server/plugin/pluginapi/mocks/mock_telemetry.go b/server/plugin/pluginapi/mocks/mock_telemetry.go new file mode 100644 index 0000000..7693e0e --- /dev/null +++ b/server/plugin/pluginapi/mocks/mock_telemetry.go @@ -0,0 +1,75 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/mattermost/mattermost/server/public/pluginapi/experimental/telemetry (interfaces: Tracker) + +// Package mocks is a generated GoMock package. +package mocks + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + telemetry "github.com/mattermost/mattermost/server/public/pluginapi/experimental/telemetry" +) + +// MockTracker is a mock of Tracker interface. +type MockTracker struct { + ctrl *gomock.Controller + recorder *MockTrackerMockRecorder +} + +// MockTrackerMockRecorder is the mock recorder for MockTracker. +type MockTrackerMockRecorder struct { + mock *MockTracker +} + +// NewMockTracker creates a new mock instance. +func NewMockTracker(ctrl *gomock.Controller) *MockTracker { + mock := &MockTracker{ctrl: ctrl} + mock.recorder = &MockTrackerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockTracker) EXPECT() *MockTrackerMockRecorder { + return m.recorder +} + +// ReloadConfig mocks base method. +func (m *MockTracker) ReloadConfig(arg0 telemetry.TrackerConfig) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "ReloadConfig", arg0) +} + +// ReloadConfig indicates an expected call of ReloadConfig. +func (mr *MockTrackerMockRecorder) ReloadConfig(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReloadConfig", reflect.TypeOf((*MockTracker)(nil).ReloadConfig), arg0) +} + +// TrackEvent mocks base method. +func (m *MockTracker) TrackEvent(arg0 string, arg1 map[string]interface{}) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "TrackEvent", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// TrackEvent indicates an expected call of TrackEvent. +func (mr *MockTrackerMockRecorder) TrackEvent(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TrackEvent", reflect.TypeOf((*MockTracker)(nil).TrackEvent), arg0, arg1) +} + +// TrackUserEvent mocks base method. +func (m *MockTracker) TrackUserEvent(arg0, arg1 string, arg2 map[string]interface{}) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "TrackUserEvent", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// TrackUserEvent indicates an expected call of TrackUserEvent. +func (mr *MockTrackerMockRecorder) TrackUserEvent(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TrackUserEvent", reflect.TypeOf((*MockTracker)(nil).TrackUserEvent), arg0, arg1, arg2) +} diff --git a/server/plugin/test_utils.go b/server/plugin/test_utils.go index 0a14ce3..b9661cf 100644 --- a/server/plugin/test_utils.go +++ b/server/plugin/test_utils.go @@ -12,12 +12,16 @@ import ( "google.golang.org/api/sheets/v4" "google.golang.org/api/slides/v1" + "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/config" "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/model" + mock_pluginapi "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/pluginapi/mocks" mock_google "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/google/mocks" mock_store "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/kvstore/mocks" + + mock_oauth2 "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/oauth2/mocks" ) type TestEnvironment struct { @@ -26,8 +30,10 @@ type TestEnvironment struct { } func SetupTestEnvironment(t *testing.T) *TestEnvironment { + p := Plugin{ - BotUserID: "bot_user_id", + BotUserID: "bot_user_id", + oauthBroker: NewOAuthBroker(func(event OAuthCompleteEvent) {}), } e := &TestEnvironment{ @@ -46,11 +52,18 @@ func (e *TestEnvironment) Cleanup(t *testing.T) { func (e *TestEnvironment) ResetMocks(t *testing.T) { e.mockAPI = &plugintest.API{} e.plugin.SetAPI(e.mockAPI) - e.plugin.Client = pluginapi.NewClient(e.plugin.API, e.plugin.Driver) + e.plugin.Client = pluginapi.NewClient(e.mockAPI, e.plugin.Driver) + e.plugin.configuration = &config.Configuration{ + QueriesPerMinute: 60, + BurstSize: 10, + GoogleOAuthClientID: "randomstring.apps.googleusercontent.com", + GoogleOAuthClientSecret: "googleoauthclientsecret", + EncryptionKey: "encryptionkey123", + } } // revive:disable-next-line:unexported-return -func GetMockSetup(t *testing.T) (*mock_store.MockKVStore, *mock_google.MockClientInterface, *mock_google.MockDriveInterface, *mock_google.MockDriveActivityInterface, *mock_google.MockDocsInterface, *mock_google.MockSheetsInterface, *mock_google.MockSlidesInterface, *mock_pluginapi.MockClusterMutex, *mock_pluginapi.MockCluster) { +func GetMockSetup(t *testing.T) (*mock_store.MockKVStore, *mock_google.MockClientInterface, *mock_google.MockDriveInterface, *mock_google.MockDriveActivityInterface, *mock_google.MockDocsInterface, *mock_google.MockSheetsInterface, *mock_google.MockSlidesInterface, *mock_pluginapi.MockClusterMutex, *mock_pluginapi.MockCluster, *mock_oauth2.MockConfigInterface, *mock_pluginapi.MockTracker) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -63,8 +76,10 @@ func GetMockSetup(t *testing.T) (*mock_store.MockKVStore, *mock_google.MockClien mockGoogleSlides := mock_google.NewMockSlidesInterface(ctrl) mockClusterMutex := mock_pluginapi.NewMockClusterMutex(ctrl) mockCluster := mock_pluginapi.NewMockCluster(ctrl) + mockOAuth2 := mock_oauth2.NewMockConfigInterface(ctrl) + mockTelemetry := mock_pluginapi.NewMockTracker(ctrl) - return mockKvStore, mockGoogleClient, mockGoogleDrive, mockDriveActivity, mockGoogleDocs, mockGoogleSheets, mockGoogleSlides, mockClusterMutex, mockCluster + return mockKvStore, mockGoogleClient, mockGoogleDrive, mockDriveActivity, mockGoogleDocs, mockGoogleSheets, mockGoogleSlides, mockClusterMutex, mockCluster, mockOAuth2, mockTelemetry } func GetSampleChangeList() *drive.ChangeList { From 1c6c10005bb5a15b3993d3b36c9e143696492e9c Mon Sep 17 00:00:00 2001 From: Benjamin Cooke Date: Wed, 6 Nov 2024 16:31:58 -0500 Subject: [PATCH 16/17] lint --- server/plugin/oauth2/oauth2.go | 3 ++- server/plugin/test_utils.go | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/plugin/oauth2/oauth2.go b/server/plugin/oauth2/oauth2.go index 622446a..8282724 100644 --- a/server/plugin/oauth2/oauth2.go +++ b/server/plugin/oauth2/oauth2.go @@ -4,9 +4,10 @@ import ( "context" "fmt" - "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/config" oauth2package "golang.org/x/oauth2" "golang.org/x/oauth2/google" + + "github.com/mattermost-community/mattermost-plugin-google-drive/server/plugin/config" ) type ConfigWrapper struct { diff --git a/server/plugin/test_utils.go b/server/plugin/test_utils.go index b9661cf..c324928 100644 --- a/server/plugin/test_utils.go +++ b/server/plugin/test_utils.go @@ -30,7 +30,6 @@ type TestEnvironment struct { } func SetupTestEnvironment(t *testing.T) *TestEnvironment { - p := Plugin{ BotUserID: "bot_user_id", oauthBroker: NewOAuthBroker(func(event OAuthCompleteEvent) {}), From 88daa3954cdb84eca5aebf47a596e25af247f1e5 Mon Sep 17 00:00:00 2001 From: Benjamin Cooke Date: Fri, 22 Nov 2024 12:11:39 -0500 Subject: [PATCH 17/17] GetMockSetup returns a struct, renamed mock file --- server/plugin/api_test.go | 236 +++++++++--------- .../{mutex_mock.go => mock_mutex.go} | 0 server/plugin/test_utils.go | 43 ++-- 3 files changed, 146 insertions(+), 133 deletions(-) rename server/plugin/pluginapi/{mutex_mock.go => mock_mutex.go} (100%) diff --git a/server/plugin/api_test.go b/server/plugin/api_test.go index 98d59e2..adff961 100644 --- a/server/plugin/api_test.go +++ b/server/plugin/api_test.go @@ -29,7 +29,7 @@ import ( ) func TestNotificationWebhook(t *testing.T) { - mockKvStore, mockGoogleClient, mockGoogleDrive, mockDriveActivity, _, _, _, _, mockCluster, _, _ := GetMockSetup(t) + mocks := GetMockSetup(t) for name, test := range map[string]struct { expectedStatusCode int @@ -47,7 +47,7 @@ func TestNotificationWebhook(t *testing.T) { Token: "", PageToken: "", } - mockKvStore.EXPECT().GetWatchChannelData("").Return(watchChannelData, nil) + mocks.MockKVStore.EXPECT().GetWatchChannelData("").Return(watchChannelData, nil) te.mockAPI.On("LogError", mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string")).Maybe() }, modifyRequest: func(r *http.Request) *http.Request { @@ -59,7 +59,7 @@ func TestNotificationWebhook(t *testing.T) { expectedStatusCode: http.StatusBadRequest, envSetup: func(te *TestEnvironment) { watchChannelData := GetSampleWatchChannelData() - mockKvStore.EXPECT().GetWatchChannelData("userId1").Return(watchChannelData, nil) + mocks.MockKVStore.EXPECT().GetWatchChannelData("userId1").Return(watchChannelData, nil) te.mockAPI.On("LogError", mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string")).Maybe() }, modifyRequest: func(r *http.Request) *http.Request { @@ -78,14 +78,14 @@ func TestNotificationWebhook(t *testing.T) { Token: "token1", PageToken: "", } - mockKvStore.EXPECT().GetWatchChannelData("userId1").Return(watchChannelData, nil).MaxTimes(2) - mockGoogleClient.EXPECT().NewDriveService(context.Background(), "userId1").Return(mockGoogleDrive, nil) - mockCluster.EXPECT().NewMutex("drive_watch_notifications_userId1").Return(pluginapi.NewClusterMutexMock(), nil) + mocks.MockKVStore.EXPECT().GetWatchChannelData("userId1").Return(watchChannelData, nil).MaxTimes(2) + mocks.MockGoogleClient.EXPECT().NewDriveService(context.Background(), "userId1").Return(mocks.MockGoogleDrive, nil) + mocks.MockCluster.EXPECT().NewMutex("drive_watch_notifications_userId1").Return(pluginapi.NewClusterMutexMock(), nil) te.mockAPI.On("KVSetWithOptions", "mutex_drive_watch_notifications_userId1", mock.Anything, mock.Anything).Return(true, nil) - mockGoogleDrive.EXPECT().GetStartPageToken(context.Background()).Return(&drive.StartPageToken{ + mocks.MockGoogleDrive.EXPECT().GetStartPageToken(context.Background()).Return(&drive.StartPageToken{ StartPageToken: "newPageToken1", }, nil) - mockGoogleDrive.EXPECT().ChangesList(context.Background(), "newPageToken1").Return(&drive.ChangeList{NewStartPageToken: "newPageToken2"}, nil) + mocks.MockGoogleDrive.EXPECT().ChangesList(context.Background(), "newPageToken1").Return(&drive.ChangeList{NewStartPageToken: "newPageToken2"}, nil) newWatchChannelData := &model.WatchChannelData{ ChannelID: "channelId1", ResourceID: "resourceId1", @@ -94,32 +94,32 @@ func TestNotificationWebhook(t *testing.T) { Token: "token1", PageToken: "newPageToken2", } - mockKvStore.EXPECT().StoreWatchChannelData("userId1", *newWatchChannelData).Return(nil) + mocks.MockKVStore.EXPECT().StoreWatchChannelData("userId1", *newWatchChannelData).Return(nil) }, }, "Ensure we only hit the changelist a maximum of 5 times": { expectedStatusCode: http.StatusOK, envSetup: func(te *TestEnvironment) { watchChannelData := GetSampleWatchChannelData() - mockKvStore.EXPECT().GetWatchChannelData("userId1").Return(watchChannelData, nil).MaxTimes(2) - mockGoogleClient.EXPECT().NewDriveService(context.Background(), "userId1").Return(mockGoogleDrive, nil) - mockCluster.EXPECT().NewMutex("drive_watch_notifications_userId1").Return(pluginapi.NewClusterMutexMock(), nil) + mocks.MockKVStore.EXPECT().GetWatchChannelData("userId1").Return(watchChannelData, nil).MaxTimes(2) + mocks.MockGoogleClient.EXPECT().NewDriveService(context.Background(), "userId1").Return(mocks.MockGoogleDrive, nil) + mocks.MockCluster.EXPECT().NewMutex("drive_watch_notifications_userId1").Return(pluginapi.NewClusterMutexMock(), nil) te.mockAPI.On("KVSetWithOptions", "mutex_drive_watch_notifications_userId1", mock.Anything, mock.Anything).Return(true, nil) - mockGoogleDrive.EXPECT().ChangesList(context.Background(), "pageToken1").Return(&drive.ChangeList{NewStartPageToken: "", NextPageToken: "pageToken1", Changes: []*drive.Change{}}, nil).MaxTimes(5) - mockKvStore.EXPECT().StoreWatchChannelData("userId1", *watchChannelData).Return(nil) + mocks.MockGoogleDrive.EXPECT().ChangesList(context.Background(), "pageToken1").Return(&drive.ChangeList{NewStartPageToken: "", NextPageToken: "pageToken1", Changes: []*drive.Change{}}, nil).MaxTimes(5) + mocks.MockKVStore.EXPECT().StoreWatchChannelData("userId1", *watchChannelData).Return(nil) }, }, "Ensure we don't send the user a notification if they have opened the file since the last change": { expectedStatusCode: http.StatusOK, envSetup: func(te *TestEnvironment) { watchChannelData := GetSampleWatchChannelData() - mockKvStore.EXPECT().GetWatchChannelData("userId1").Return(watchChannelData, nil).MaxTimes(2) - mockGoogleClient.EXPECT().NewDriveService(context.Background(), "userId1").Return(mockGoogleDrive, nil) - mockCluster.EXPECT().NewMutex("drive_watch_notifications_userId1").Return(pluginapi.NewClusterMutexMock(), nil) + mocks.MockKVStore.EXPECT().GetWatchChannelData("userId1").Return(watchChannelData, nil).MaxTimes(2) + mocks.MockGoogleClient.EXPECT().NewDriveService(context.Background(), "userId1").Return(mocks.MockGoogleDrive, nil) + mocks.MockCluster.EXPECT().NewMutex("drive_watch_notifications_userId1").Return(pluginapi.NewClusterMutexMock(), nil) te.mockAPI.On("KVSetWithOptions", "mutex_drive_watch_notifications_userId1", mock.Anything, mock.Anything).Return(true, nil) changeList := GetSampleChangeList() changeList.Changes[0].File.ViewedByMeTime = "2021-01-02T00:00:00.000Z" - mockGoogleDrive.EXPECT().ChangesList(context.Background(), watchChannelData.PageToken).Return(changeList, nil).MaxTimes(1) + mocks.MockGoogleDrive.EXPECT().ChangesList(context.Background(), watchChannelData.PageToken).Return(changeList, nil).MaxTimes(1) watchChannelData = &model.WatchChannelData{ ChannelID: "channelId1", ResourceID: "resourceId1", @@ -128,21 +128,21 @@ func TestNotificationWebhook(t *testing.T) { Token: "token1", PageToken: changeList.NewStartPageToken, } - mockKvStore.EXPECT().StoreWatchChannelData("userId1", *watchChannelData).Return(nil) - mockGoogleClient.EXPECT().NewDriveActivityService(context.Background(), "userId1").Return(mockDriveActivity, nil) - mockKvStore.EXPECT().StoreLastActivityForFile("userId1", "fileId1", "2021-01-02T00:00:00.000Z").Return(nil) + mocks.MockKVStore.EXPECT().StoreWatchChannelData("userId1", *watchChannelData).Return(nil) + mocks.MockGoogleClient.EXPECT().NewDriveActivityService(context.Background(), "userId1").Return(mocks.MockDriveActivity, nil) + mocks.MockKVStore.EXPECT().StoreLastActivityForFile("userId1", "fileId1", "2021-01-02T00:00:00.000Z").Return(nil) }, }, "Ensure we only hit the drive activity api a maximum of 5 times": { expectedStatusCode: http.StatusOK, envSetup: func(te *TestEnvironment) { watchChannelData := GetSampleWatchChannelData() - mockKvStore.EXPECT().GetWatchChannelData("userId1").Return(watchChannelData, nil).MaxTimes(2) - mockGoogleClient.EXPECT().NewDriveService(context.Background(), "userId1").Return(mockGoogleDrive, nil) - mockCluster.EXPECT().NewMutex("drive_watch_notifications_userId1").Return(pluginapi.NewClusterMutexMock(), nil) + mocks.MockKVStore.EXPECT().GetWatchChannelData("userId1").Return(watchChannelData, nil).MaxTimes(2) + mocks.MockGoogleClient.EXPECT().NewDriveService(context.Background(), "userId1").Return(mocks.MockGoogleDrive, nil) + mocks.MockCluster.EXPECT().NewMutex("drive_watch_notifications_userId1").Return(pluginapi.NewClusterMutexMock(), nil) te.mockAPI.On("KVSetWithOptions", "mutex_drive_watch_notifications_userId1", mock.Anything, mock.Anything).Return(true, nil) changeList := GetSampleChangeList() - mockGoogleDrive.EXPECT().ChangesList(context.Background(), watchChannelData.PageToken).Return(changeList, nil).MaxTimes(1) + mocks.MockGoogleDrive.EXPECT().ChangesList(context.Background(), watchChannelData.PageToken).Return(changeList, nil).MaxTimes(1) watchChannelData = &model.WatchChannelData{ ChannelID: "channelId1", ResourceID: "resourceId1", @@ -151,31 +151,31 @@ func TestNotificationWebhook(t *testing.T) { Token: "token1", PageToken: changeList.NewStartPageToken, } - mockKvStore.EXPECT().StoreWatchChannelData("userId1", *watchChannelData).Return(nil) - mockGoogleClient.EXPECT().NewDriveActivityService(context.Background(), "userId1").Return(mockDriveActivity, nil) - mockKvStore.EXPECT().GetLastActivityForFile("userId1", changeList.Changes[0].File.Id).Return(changeList.Changes[0].File.ModifiedTime, nil) - mockDriveActivity.EXPECT().Query(context.Background(), &driveactivity.QueryDriveActivityRequest{ + mocks.MockKVStore.EXPECT().StoreWatchChannelData("userId1", *watchChannelData).Return(nil) + mocks.MockGoogleClient.EXPECT().NewDriveActivityService(context.Background(), "userId1").Return(mocks.MockDriveActivity, nil) + mocks.MockKVStore.EXPECT().GetLastActivityForFile("userId1", changeList.Changes[0].File.Id).Return(changeList.Changes[0].File.ModifiedTime, nil) + mocks.MockDriveActivity.EXPECT().Query(context.Background(), &driveactivity.QueryDriveActivityRequest{ ItemName: fmt.Sprintf("items/%s", changeList.Changes[0].File.Id), Filter: "time > \"" + changeList.Changes[0].File.ModifiedTime + "\"", }).Return(&driveactivity.QueryDriveActivityResponse{Activities: []*driveactivity.DriveActivity{}, NextPageToken: "newPage"}, nil).MaxTimes(1) - mockDriveActivity.EXPECT().Query(context.Background(), &driveactivity.QueryDriveActivityRequest{ + mocks.MockDriveActivity.EXPECT().Query(context.Background(), &driveactivity.QueryDriveActivityRequest{ ItemName: fmt.Sprintf("items/%s", changeList.Changes[0].File.Id), Filter: "time > \"" + changeList.Changes[0].File.ModifiedTime + "\"", PageToken: "newPage", }).Return(&driveactivity.QueryDriveActivityResponse{Activities: []*driveactivity.DriveActivity{}, NextPageToken: "newPage"}, nil).MaxTimes(4) - mockKvStore.EXPECT().StoreLastActivityForFile("userId1", changeList.Changes[0].File.Id, changeList.Changes[0].File.ModifiedTime).Return(nil) + mocks.MockKVStore.EXPECT().StoreLastActivityForFile("userId1", changeList.Changes[0].File.Id, changeList.Changes[0].File.ModifiedTime).Return(nil) }, }, "Send one bot DM if there are more than 6 activities in a file": { expectedStatusCode: http.StatusOK, envSetup: func(te *TestEnvironment) { watchChannelData := GetSampleWatchChannelData() - mockKvStore.EXPECT().GetWatchChannelData("userId1").Return(watchChannelData, nil).MaxTimes(2) - mockGoogleClient.EXPECT().NewDriveService(context.Background(), "userId1").Return(mockGoogleDrive, nil) - mockCluster.EXPECT().NewMutex("drive_watch_notifications_userId1").Return(pluginapi.NewClusterMutexMock(), nil) + mocks.MockKVStore.EXPECT().GetWatchChannelData("userId1").Return(watchChannelData, nil).MaxTimes(2) + mocks.MockGoogleClient.EXPECT().NewDriveService(context.Background(), "userId1").Return(mocks.MockGoogleDrive, nil) + mocks.MockCluster.EXPECT().NewMutex("drive_watch_notifications_userId1").Return(pluginapi.NewClusterMutexMock(), nil) te.mockAPI.On("KVSetWithOptions", "mutex_drive_watch_notifications_userId1", mock.Anything, mock.Anything).Return(true, nil) changeList := GetSampleChangeList() - mockGoogleDrive.EXPECT().ChangesList(context.Background(), watchChannelData.PageToken).Return(changeList, nil).MaxTimes(1) + mocks.MockGoogleDrive.EXPECT().ChangesList(context.Background(), watchChannelData.PageToken).Return(changeList, nil).MaxTimes(1) watchChannelData = &model.WatchChannelData{ ChannelID: "channelId1", ResourceID: "resourceId1", @@ -184,10 +184,10 @@ func TestNotificationWebhook(t *testing.T) { Token: "token1", PageToken: changeList.NewStartPageToken, } - mockKvStore.EXPECT().StoreWatchChannelData("userId1", *watchChannelData).Return(nil) - mockGoogleClient.EXPECT().NewDriveActivityService(context.Background(), "userId1").Return(mockDriveActivity, nil) - mockKvStore.EXPECT().GetLastActivityForFile("userId1", changeList.Changes[0].File.Id).Return(changeList.Changes[0].File.ModifiedTime, nil) - mockDriveActivity.EXPECT().Query(context.Background(), &driveactivity.QueryDriveActivityRequest{ + mocks.MockKVStore.EXPECT().StoreWatchChannelData("userId1", *watchChannelData).Return(nil) + mocks.MockGoogleClient.EXPECT().NewDriveActivityService(context.Background(), "userId1").Return(mocks.MockDriveActivity, nil) + mocks.MockKVStore.EXPECT().GetLastActivityForFile("userId1", changeList.Changes[0].File.Id).Return(changeList.Changes[0].File.ModifiedTime, nil) + mocks.MockDriveActivity.EXPECT().Query(context.Background(), &driveactivity.QueryDriveActivityRequest{ ItemName: fmt.Sprintf("items/%s", changeList.Changes[0].File.Id), Filter: "time > \"" + changeList.Changes[0].File.ModifiedTime + "\"", }).Return(&driveactivity.QueryDriveActivityResponse{Activities: []*driveactivity.DriveActivity{ @@ -224,19 +224,19 @@ func TestNotificationWebhook(t *testing.T) { }, NextPageToken: ""}, nil).MaxTimes(1) te.mockAPI.On("GetDirectChannel", "userId1", te.plugin.BotUserID).Return(&mattermostModel.Channel{Id: "channelId1"}, nil).Times(1) te.mockAPI.On("CreatePost", mock.Anything).Return(nil, nil).Times(1) - mockKvStore.EXPECT().StoreLastActivityForFile("userId1", changeList.Changes[0].File.Id, changeList.Changes[0].File.ModifiedTime).Return(nil) + mocks.MockKVStore.EXPECT().StoreLastActivityForFile("userId1", changeList.Changes[0].File.Id, changeList.Changes[0].File.ModifiedTime).Return(nil) }, }, "Send a notification for a permission change on a file": { expectedStatusCode: http.StatusOK, envSetup: func(te *TestEnvironment) { watchChannelData := GetSampleWatchChannelData() - mockKvStore.EXPECT().GetWatchChannelData("userId1").Return(watchChannelData, nil).MaxTimes(2) - mockGoogleClient.EXPECT().NewDriveService(context.Background(), "userId1").Return(mockGoogleDrive, nil) - mockCluster.EXPECT().NewMutex("drive_watch_notifications_userId1").Return(pluginapi.NewClusterMutexMock(), nil) + mocks.MockKVStore.EXPECT().GetWatchChannelData("userId1").Return(watchChannelData, nil).MaxTimes(2) + mocks.MockGoogleClient.EXPECT().NewDriveService(context.Background(), "userId1").Return(mocks.MockGoogleDrive, nil) + mocks.MockCluster.EXPECT().NewMutex("drive_watch_notifications_userId1").Return(pluginapi.NewClusterMutexMock(), nil) te.mockAPI.On("KVSetWithOptions", "mutex_drive_watch_notifications_userId1", mock.Anything, mock.Anything).Return(true, nil) changeList := GetSampleChangeList() - mockGoogleDrive.EXPECT().ChangesList(context.Background(), watchChannelData.PageToken).Return(changeList, nil).MaxTimes(1) + mocks.MockGoogleDrive.EXPECT().ChangesList(context.Background(), watchChannelData.PageToken).Return(changeList, nil).MaxTimes(1) watchChannelData = &model.WatchChannelData{ ChannelID: "channelId1", ResourceID: "resourceId1", @@ -245,10 +245,10 @@ func TestNotificationWebhook(t *testing.T) { Token: "token1", PageToken: changeList.NewStartPageToken, } - mockKvStore.EXPECT().StoreWatchChannelData("userId1", *watchChannelData).Return(nil) - mockGoogleClient.EXPECT().NewDriveActivityService(context.Background(), "userId1").Return(mockDriveActivity, nil) - mockKvStore.EXPECT().GetLastActivityForFile("userId1", changeList.Changes[0].File.Id).Return(changeList.Changes[0].File.ModifiedTime, nil) - mockDriveActivity.EXPECT().Query(context.Background(), &driveactivity.QueryDriveActivityRequest{ + mocks.MockKVStore.EXPECT().StoreWatchChannelData("userId1", *watchChannelData).Return(nil) + mocks.MockGoogleClient.EXPECT().NewDriveActivityService(context.Background(), "userId1").Return(mocks.MockDriveActivity, nil) + mocks.MockKVStore.EXPECT().GetLastActivityForFile("userId1", changeList.Changes[0].File.Id).Return(changeList.Changes[0].File.ModifiedTime, nil) + mocks.MockDriveActivity.EXPECT().Query(context.Background(), &driveactivity.QueryDriveActivityRequest{ ItemName: fmt.Sprintf("items/%s", changeList.Changes[0].File.Id), Filter: "time > \"" + changeList.Changes[0].File.ModifiedTime + "\"", }).Return(GetSampleDriveactivityPermissionResponse(), nil).MaxTimes(1) @@ -268,19 +268,19 @@ func TestNotificationWebhook(t *testing.T) { }, } te.mockAPI.On("CreatePost", post).Return(nil, nil).Times(1) - mockKvStore.EXPECT().StoreLastActivityForFile("userId1", changeList.Changes[0].File.Id, changeList.Changes[0].File.ModifiedTime).Return(nil) + mocks.MockKVStore.EXPECT().StoreLastActivityForFile("userId1", changeList.Changes[0].File.Id, changeList.Changes[0].File.ModifiedTime).Return(nil) }, }, "Send a notification for a comment on a file": { expectedStatusCode: http.StatusOK, envSetup: func(te *TestEnvironment) { watchChannelData := GetSampleWatchChannelData() - mockKvStore.EXPECT().GetWatchChannelData("userId1").Return(watchChannelData, nil).MaxTimes(2) - mockGoogleClient.EXPECT().NewDriveService(context.Background(), "userId1").Return(mockGoogleDrive, nil) - mockCluster.EXPECT().NewMutex("drive_watch_notifications_userId1").Return(pluginapi.NewClusterMutexMock(), nil) + mocks.MockKVStore.EXPECT().GetWatchChannelData("userId1").Return(watchChannelData, nil).MaxTimes(2) + mocks.MockGoogleClient.EXPECT().NewDriveService(context.Background(), "userId1").Return(mocks.MockGoogleDrive, nil) + mocks.MockCluster.EXPECT().NewMutex("drive_watch_notifications_userId1").Return(pluginapi.NewClusterMutexMock(), nil) te.mockAPI.On("KVSetWithOptions", "mutex_drive_watch_notifications_userId1", mock.Anything, mock.Anything).Return(true, nil) changeList := GetSampleChangeList() - mockGoogleDrive.EXPECT().ChangesList(context.Background(), watchChannelData.PageToken).Return(changeList, nil).MaxTimes(1) + mocks.MockGoogleDrive.EXPECT().ChangesList(context.Background(), watchChannelData.PageToken).Return(changeList, nil).MaxTimes(1) watchChannelData = &model.WatchChannelData{ ChannelID: "channelId1", ResourceID: "resourceId1", @@ -289,18 +289,18 @@ func TestNotificationWebhook(t *testing.T) { Token: "token1", PageToken: changeList.NewStartPageToken, } - mockKvStore.EXPECT().StoreWatchChannelData("userId1", *watchChannelData).Return(nil) - mockGoogleClient.EXPECT().NewDriveActivityService(context.Background(), "userId1").Return(mockDriveActivity, nil) + mocks.MockKVStore.EXPECT().StoreWatchChannelData("userId1", *watchChannelData).Return(nil) + mocks.MockGoogleClient.EXPECT().NewDriveActivityService(context.Background(), "userId1").Return(mocks.MockDriveActivity, nil) file := changeList.Changes[0].File - mockKvStore.EXPECT().GetLastActivityForFile("userId1", file.Id).Return(file.ModifiedTime, nil) + mocks.MockKVStore.EXPECT().GetLastActivityForFile("userId1", file.Id).Return(file.ModifiedTime, nil) activityResponse := GetSampleDriveactivityCommentResponse() - mockDriveActivity.EXPECT().Query(context.Background(), &driveactivity.QueryDriveActivityRequest{ + mocks.MockDriveActivity.EXPECT().Query(context.Background(), &driveactivity.QueryDriveActivityRequest{ ItemName: fmt.Sprintf("items/%s", file.Id), Filter: "time > \"" + file.ModifiedTime + "\"", }).Return(activityResponse, nil).MaxTimes(1) commentID := activityResponse.Activities[0].Targets[0].FileComment.LegacyCommentId comment := GetSampleComment(commentID) - mockGoogleDrive.EXPECT().GetComments(context.Background(), file.Id, commentID).Return(comment, nil) + mocks.MockGoogleDrive.EXPECT().GetComments(context.Background(), file.Id, commentID).Return(comment, nil) siteURL := "http://localhost" te.mockAPI.On("GetConfig").Return(&mattermostModel.Config{ServiceSettings: mattermostModel.ServiceSettings{SiteURL: &siteURL}}) te.mockAPI.On("GetDirectChannel", "userId1", te.plugin.BotUserID).Return(&mattermostModel.Channel{Id: "channelId1"}, nil).Times(1) @@ -330,7 +330,7 @@ func TestNotificationWebhook(t *testing.T) { }, } te.mockAPI.On("CreatePost", post).Return(nil, nil).Times(1) - mockKvStore.EXPECT().StoreLastActivityForFile("userId1", changeList.Changes[0].File.Id, changeList.Changes[0].File.ModifiedTime).Return(nil) + mocks.MockKVStore.EXPECT().StoreLastActivityForFile("userId1", changeList.Changes[0].File.Id, changeList.Changes[0].File.ModifiedTime).Return(nil) }, }, } { @@ -339,8 +339,8 @@ func TestNotificationWebhook(t *testing.T) { te := SetupTestEnvironment(t) defer te.Cleanup(t) - te.plugin.KVStore = mockKvStore - te.plugin.GoogleClient = mockGoogleClient + te.plugin.KVStore = mocks.MockKVStore + te.plugin.GoogleClient = mocks.MockGoogleClient te.plugin.initializeAPI() test.envSetup(te) @@ -368,7 +368,7 @@ func TestNotificationWebhook(t *testing.T) { } func TestFileCreationEndpoint(t *testing.T) { - mockKvStore, mockGoogleClient, mockGoogleDrive, _, mockGoogleDocs, mockGoogleSheets, mockGoogleSlides, _, _, _, _ := GetMockSetup(t) + mocks := GetMockSetup(t) for name, test := range map[string]struct { expectedStatusCode int @@ -403,19 +403,19 @@ func TestFileCreationEndpoint(t *testing.T) { }, }, envSetup: func(ctx context.Context, te *TestEnvironment) { - mockGoogleClient.EXPECT().NewDocsService(ctx, "userId1").Return(mockGoogleDocs, nil) + mocks.MockGoogleClient.EXPECT().NewDocsService(ctx, "userId1").Return(mocks.MockGoogleDocs, nil) doc := GetSampleDoc() - mockGoogleDocs.EXPECT().Create(ctx, &docs.Document{ + mocks.MockGoogleDocs.EXPECT().Create(ctx, &docs.Document{ Title: "file name", }).Return(doc, nil) - mockGoogleClient.EXPECT().NewDriveService(ctx, "userId1").Return(mockGoogleDrive, nil).Times(2) + mocks.MockGoogleClient.EXPECT().NewDriveService(ctx, "userId1").Return(mocks.MockGoogleDrive, nil).Times(2) te.mockAPI.On("GetConfig").Return(nil) - mockGoogleDrive.EXPECT().CreatePermission(ctx, doc.DocumentId, &drive.Permission{ + mocks.MockGoogleDrive.EXPECT().CreatePermission(ctx, doc.DocumentId, &drive.Permission{ Role: "commenter", Type: "anyone", }).Return(&drive.Permission{}, nil).MaxTimes(1) file := GetSampleFile(doc.DocumentId) - mockGoogleDrive.EXPECT().GetFile(ctx, doc.DocumentId).Return(file, nil) + mocks.MockGoogleDrive.EXPECT().GetFile(ctx, doc.DocumentId).Return(file, nil) te.mockAPI.On("GetDirectChannel", "userId1", te.plugin.BotUserID).Return(&mattermostModel.Channel{Id: "channelId1"}, nil).Times(1) te.mockAPI.On("CreatePost", mock.Anything).Return(nil, nil).Times(1) }, @@ -432,21 +432,21 @@ func TestFileCreationEndpoint(t *testing.T) { }, }, envSetup: func(ctx context.Context, te *TestEnvironment) { - mockGoogleClient.EXPECT().NewSheetsService(ctx, "userId1").Return(mockGoogleSheets, nil) + mocks.MockGoogleClient.EXPECT().NewSheetsService(ctx, "userId1").Return(mocks.MockGoogleSheets, nil) sheet := GetSampleSheet() - mockGoogleSheets.EXPECT().Create(ctx, &sheets.Spreadsheet{ + mocks.MockGoogleSheets.EXPECT().Create(ctx, &sheets.Spreadsheet{ Properties: &sheets.SpreadsheetProperties{ Title: "file name", }, }).Return(sheet, nil) - mockGoogleClient.EXPECT().NewDriveService(ctx, "userId1").Return(mockGoogleDrive, nil).Times(2) + mocks.MockGoogleClient.EXPECT().NewDriveService(ctx, "userId1").Return(mocks.MockGoogleDrive, nil).Times(2) te.mockAPI.On("GetConfig").Return(nil) - mockGoogleDrive.EXPECT().CreatePermission(ctx, sheet.SpreadsheetId, &drive.Permission{ + mocks.MockGoogleDrive.EXPECT().CreatePermission(ctx, sheet.SpreadsheetId, &drive.Permission{ Role: "writer", Type: "anyone", }).Return(&drive.Permission{}, nil).MaxTimes(1) file := GetSampleFile(sheet.SpreadsheetId) - mockGoogleDrive.EXPECT().GetFile(ctx, sheet.SpreadsheetId).Return(file, nil) + mocks.MockGoogleDrive.EXPECT().GetFile(ctx, sheet.SpreadsheetId).Return(file, nil) te.mockAPI.On("GetDirectChannel", "userId1", te.plugin.BotUserID).Return(&mattermostModel.Channel{Id: "channelId1"}, nil).Times(1) te.mockAPI.On("CreatePost", mock.Anything).Return(nil, nil).Times(1) }, @@ -463,19 +463,19 @@ func TestFileCreationEndpoint(t *testing.T) { }, }, envSetup: func(ctx context.Context, te *TestEnvironment) { - mockGoogleClient.EXPECT().NewSlidesService(ctx, "userId1").Return(mockGoogleSlides, nil) + mocks.MockGoogleClient.EXPECT().NewSlidesService(ctx, "userId1").Return(mocks.MockGoogleSlides, nil) presentation := GetSamplePresentation() - mockGoogleSlides.EXPECT().Create(ctx, &slides.Presentation{ + mocks.MockGoogleSlides.EXPECT().Create(ctx, &slides.Presentation{ Title: "file name", }).Return(presentation, nil) - mockGoogleClient.EXPECT().NewDriveService(ctx, "userId1").Return(mockGoogleDrive, nil).Times(2) + mocks.MockGoogleClient.EXPECT().NewDriveService(ctx, "userId1").Return(mocks.MockGoogleDrive, nil).Times(2) te.mockAPI.On("GetConfig").Return(nil) - mockGoogleDrive.EXPECT().CreatePermission(ctx, presentation.PresentationId, &drive.Permission{ + mocks.MockGoogleDrive.EXPECT().CreatePermission(ctx, presentation.PresentationId, &drive.Permission{ Role: "reader", Type: "anyone", }).Return(&drive.Permission{}, nil).MaxTimes(1) file := GetSampleFile(presentation.PresentationId) - mockGoogleDrive.EXPECT().GetFile(ctx, presentation.PresentationId).Return(file, nil) + mocks.MockGoogleDrive.EXPECT().GetFile(ctx, presentation.PresentationId).Return(file, nil) te.mockAPI.On("GetDirectChannel", "userId1", te.plugin.BotUserID).Return(&mattermostModel.Channel{Id: "channelId1"}, nil).Times(1) te.mockAPI.On("CreatePost", mock.Anything).Return(nil, nil).Times(1) }, @@ -492,15 +492,15 @@ func TestFileCreationEndpoint(t *testing.T) { }, }, envSetup: func(ctx context.Context, te *TestEnvironment) { - mockGoogleClient.EXPECT().NewDocsService(ctx, "userId1").Return(mockGoogleDocs, nil) + mocks.MockGoogleClient.EXPECT().NewDocsService(ctx, "userId1").Return(mocks.MockGoogleDocs, nil) doc := GetSampleDoc() - mockGoogleDocs.EXPECT().Create(ctx, &docs.Document{ + mocks.MockGoogleDocs.EXPECT().Create(ctx, &docs.Document{ Title: "file name", }).Return(doc, nil) - mockGoogleClient.EXPECT().NewDriveService(ctx, "userId1").Return(mockGoogleDrive, nil).Times(2) + mocks.MockGoogleClient.EXPECT().NewDriveService(ctx, "userId1").Return(mocks.MockGoogleDrive, nil).Times(2) te.mockAPI.On("GetConfig").Return(nil) file := GetSampleFile(doc.DocumentId) - mockGoogleDrive.EXPECT().GetFile(ctx, doc.DocumentId).Return(file, nil) + mocks.MockGoogleDrive.EXPECT().GetFile(ctx, doc.DocumentId).Return(file, nil) te.mockAPI.On("GetDirectChannel", "userId1", te.plugin.BotUserID).Return(&mattermostModel.Channel{Id: "channelId1"}, nil).Times(1) te.mockAPI.On("CreatePost", mock.Anything).Return(nil, nil).Times(1) }, @@ -518,19 +518,19 @@ func TestFileCreationEndpoint(t *testing.T) { }, }, envSetup: func(ctx context.Context, te *TestEnvironment) { - mockGoogleClient.EXPECT().NewDocsService(ctx, "userId1").Return(mockGoogleDocs, nil) + mocks.MockGoogleClient.EXPECT().NewDocsService(ctx, "userId1").Return(mocks.MockGoogleDocs, nil) doc := GetSampleDoc() - mockGoogleDocs.EXPECT().Create(ctx, &docs.Document{ + mocks.MockGoogleDocs.EXPECT().Create(ctx, &docs.Document{ Title: "file name", }).Return(doc, nil) - mockGoogleClient.EXPECT().NewDriveService(ctx, "userId1").Return(mockGoogleDrive, nil).Times(2) + mocks.MockGoogleClient.EXPECT().NewDriveService(ctx, "userId1").Return(mocks.MockGoogleDrive, nil).Times(2) te.mockAPI.On("GetConfig").Return(nil) - mockGoogleDrive.EXPECT().CreatePermission(ctx, doc.DocumentId, &drive.Permission{ + mocks.MockGoogleDrive.EXPECT().CreatePermission(ctx, doc.DocumentId, &drive.Permission{ Role: "commenter", Type: "anyone", }).Return(&drive.Permission{}, nil).MaxTimes(1) file := GetSampleFile(doc.DocumentId) - mockGoogleDrive.EXPECT().GetFile(ctx, doc.DocumentId).Return(file, nil) + mocks.MockGoogleDrive.EXPECT().GetFile(ctx, doc.DocumentId).Return(file, nil) createdTime, err := time.Parse(time.RFC3339, file.CreatedTime) require.NoError(t, err) post := &mattermostModel.Post{ @@ -564,12 +564,12 @@ func TestFileCreationEndpoint(t *testing.T) { }, }, envSetup: func(ctx context.Context, te *TestEnvironment) { - mockGoogleClient.EXPECT().NewDocsService(ctx, "userId1").Return(mockGoogleDocs, nil) + mocks.MockGoogleClient.EXPECT().NewDocsService(ctx, "userId1").Return(mocks.MockGoogleDocs, nil) doc := GetSampleDoc() - mockGoogleDocs.EXPECT().Create(ctx, &docs.Document{ + mocks.MockGoogleDocs.EXPECT().Create(ctx, &docs.Document{ Title: "file name", }).Return(doc, nil) - mockGoogleClient.EXPECT().NewDriveService(ctx, "userId1").Return(mockGoogleDrive, nil).Times(2) + mocks.MockGoogleClient.EXPECT().NewDriveService(ctx, "userId1").Return(mocks.MockGoogleDrive, nil).Times(2) te.mockAPI.On("GetConfig").Return(nil) users := []*mattermostModel.User{ { @@ -584,18 +584,18 @@ func TestFileCreationEndpoint(t *testing.T) { te.mockAPI.On("GetUsersInChannel", "channelId1", "username", 0, 100).Return(users, nil).Times(1) te.mockAPI.On("GetUsersInChannel", "channelId1", "username", 1, 100).Return([]*mattermostModel.User{}, nil).Times(1) - mockGoogleDrive.EXPECT().CreatePermission(ctx, doc.DocumentId, &drive.Permission{ + mocks.MockGoogleDrive.EXPECT().CreatePermission(ctx, doc.DocumentId, &drive.Permission{ Role: "commenter", EmailAddress: users[0].Email, Type: "user", }).Return(&drive.Permission{}, nil).MaxTimes(1) - mockGoogleDrive.EXPECT().CreatePermission(ctx, doc.DocumentId, &drive.Permission{ + mocks.MockGoogleDrive.EXPECT().CreatePermission(ctx, doc.DocumentId, &drive.Permission{ Role: "commenter", EmailAddress: users[1].Email, Type: "user", }).Return(&drive.Permission{}, nil).MaxTimes(1) file := GetSampleFile(doc.DocumentId) - mockGoogleDrive.EXPECT().GetFile(ctx, doc.DocumentId).Return(file, nil) + mocks.MockGoogleDrive.EXPECT().GetFile(ctx, doc.DocumentId).Return(file, nil) createdTime, err := time.Parse(time.RFC3339, file.CreatedTime) require.NoError(t, err) post := &mattermostModel.Post{ @@ -622,8 +622,8 @@ func TestFileCreationEndpoint(t *testing.T) { te := SetupTestEnvironment(t) defer te.Cleanup(t) - te.plugin.KVStore = mockKvStore - te.plugin.GoogleClient = mockGoogleClient + te.plugin.KVStore = mocks.MockKVStore + te.plugin.GoogleClient = mocks.MockGoogleClient te.plugin.initializeAPI() w := httptest.NewRecorder() @@ -649,7 +649,7 @@ func TestFileCreationEndpoint(t *testing.T) { } func TestUploadFile(t *testing.T) { - mockKvStore, mockGoogleClient, mockGoogleDrive, _, _, _, _, _, _, _, _ := GetMockSetup(t) + mocks := GetMockSetup(t) for name, test := range map[string]struct { expectedStatusCode int @@ -690,8 +690,8 @@ func TestUploadFile(t *testing.T) { Name: "file name", }, nil) te.mockAPI.On("GetFile", "fileId1").Return([]byte{}, nil) - mockGoogleClient.EXPECT().NewDriveService(ctx, "userId1").Return(mockGoogleDrive, nil) - mockGoogleDrive.EXPECT().CreateFile(ctx, &drive.File{Name: "file name"}, []byte{}).Return(nil, nil) + mocks.MockGoogleClient.EXPECT().NewDriveService(ctx, "userId1").Return(mocks.MockGoogleDrive, nil) + mocks.MockGoogleDrive.EXPECT().CreateFile(ctx, &drive.File{Name: "file name"}, []byte{}).Return(nil, nil) te.mockAPI.On("SendEphemeralPost", "userId1", mock.Anything).Return(nil) }, }, @@ -701,8 +701,8 @@ func TestUploadFile(t *testing.T) { te := SetupTestEnvironment(t) defer te.Cleanup(t) - te.plugin.KVStore = mockKvStore - te.plugin.GoogleClient = mockGoogleClient + te.plugin.KVStore = mocks.MockKVStore + te.plugin.GoogleClient = mocks.MockGoogleClient te.plugin.initializeAPI() w := httptest.NewRecorder() @@ -728,7 +728,7 @@ func TestUploadFile(t *testing.T) { } func TestUploadMultipleFiles(t *testing.T) { - mockKvStore, mockGoogleClient, mockGoogleDrive, _, _, _, _, _, _, _, _ := GetMockSetup(t) + mocks := GetMockSetup(t) for name, test := range map[string]struct { expectedStatusCode int @@ -757,7 +757,7 @@ func TestUploadMultipleFiles(t *testing.T) { Id: "postId1", FileIds: []string{"fileId1", "fileId2"}, }, nil) - mockGoogleClient.EXPECT().NewDriveService(ctx, "userId1").Return(mockGoogleDrive, nil) + mocks.MockGoogleClient.EXPECT().NewDriveService(ctx, "userId1").Return(mocks.MockGoogleDrive, nil) te.mockAPI.On("GetFileInfo", "fileId1").Return(&mattermostModel.FileInfo{ Id: "fileId1", PostId: "postId1", @@ -770,7 +770,7 @@ func TestUploadMultipleFiles(t *testing.T) { Name: "file name", }, nil) te.mockAPI.On("GetFile", "fileId2").Return([]byte{}, nil) - mockGoogleDrive.EXPECT().CreateFile(ctx, &drive.File{Name: "file name"}, []byte{}).Return(nil, nil).Times(2) + mocks.MockGoogleDrive.EXPECT().CreateFile(ctx, &drive.File{Name: "file name"}, []byte{}).Return(nil, nil).Times(2) te.mockAPI.On("SendEphemeralPost", "userId1", mock.Anything).Return(nil) }, }, @@ -780,8 +780,8 @@ func TestUploadMultipleFiles(t *testing.T) { te := SetupTestEnvironment(t) defer te.Cleanup(t) - te.plugin.KVStore = mockKvStore - te.plugin.GoogleClient = mockGoogleClient + te.plugin.KVStore = mocks.MockKVStore + te.plugin.GoogleClient = mocks.MockGoogleClient te.plugin.initializeAPI() w := httptest.NewRecorder() @@ -807,7 +807,7 @@ func TestUploadMultipleFiles(t *testing.T) { } func TestCompleteConnectUserToGoogle(t *testing.T) { - mockKvStore, mockGoogleClient, _, _, _, _, _, _, _, mockOAuth2, mockTelemetry := GetMockSetup(t) + mocks := GetMockSetup(t) for name, test := range map[string]struct { expectedStatusCode int @@ -839,15 +839,15 @@ func TestCompleteConnectUserToGoogle(t *testing.T) { "State token does not match stored token": { expectedStatusCode: http.StatusBadRequest, envSetup: func(ctx context.Context, te *TestEnvironment) { - mockKvStore.EXPECT().GetOAuthStateToken("oauthstate_userId1").Return([]byte("randomState"), nil) - mockKvStore.EXPECT().DeleteOAuthStateToken("oauthstate_userId1").Return(nil) + mocks.MockKVStore.EXPECT().GetOAuthStateToken("oauthstate_userId1").Return([]byte("randomState"), nil) + mocks.MockKVStore.EXPECT().DeleteOAuthStateToken("oauthstate_userId1").Return(nil) }, }, "State token does not contain the correct userID": { expectedStatusCode: http.StatusUnauthorized, envSetup: func(ctx context.Context, te *TestEnvironment) { - mockKvStore.EXPECT().GetOAuthStateToken("oauthstate_userId123").Return([]byte("oauthstate_userId123"), nil) - mockKvStore.EXPECT().DeleteOAuthStateToken("oauthstate_userId123").Return(nil) + mocks.MockKVStore.EXPECT().GetOAuthStateToken("oauthstate_userId123").Return([]byte("oauthstate_userId123"), nil) + mocks.MockKVStore.EXPECT().DeleteOAuthStateToken("oauthstate_userId123").Return(nil) }, modifyRequest: func(r *http.Request) *http.Request { values := r.URL.Query() @@ -860,17 +860,17 @@ func TestCompleteConnectUserToGoogle(t *testing.T) { "Success complete oauth setup": { expectedStatusCode: http.StatusOK, envSetup: func(ctx context.Context, te *TestEnvironment) { - mockKvStore.EXPECT().GetOAuthStateToken("oauthstate_userId1").Return([]byte("oauthstate_userId1"), nil) - mockKvStore.EXPECT().DeleteOAuthStateToken("oauthstate_userId1").Return(nil) - mockOAuth2.EXPECT().Exchange(ctx, "oauthcode").Return(&oauth2.Token{ + mocks.MockKVStore.EXPECT().GetOAuthStateToken("oauthstate_userId1").Return([]byte("oauthstate_userId1"), nil) + mocks.MockKVStore.EXPECT().DeleteOAuthStateToken("oauthstate_userId1").Return(nil) + mocks.MockOAuth2.EXPECT().Exchange(ctx, "oauthcode").Return(&oauth2.Token{ AccessToken: "accessToken12345", TokenType: "Bearer", Expiry: time.Now().Add(time.Hour), }, nil) - mockKvStore.EXPECT().StoreGoogleUserToken("userId1", gomock.Any()).Return(nil) + mocks.MockKVStore.EXPECT().StoreGoogleUserToken("userId1", gomock.Any()).Return(nil) te.mockAPI.On("GetDirectChannel", "userId1", te.plugin.BotUserID).Return(&mattermostModel.Channel{Id: "channelId1"}, nil).Times(1) te.mockAPI.On("CreatePost", mock.Anything).Return(nil, nil).Times(1) - mockTelemetry.EXPECT().TrackUserEvent("account_connected", "userId1", nil) + mocks.MockTelemetry.EXPECT().TrackUserEvent("account_connected", "userId1", nil) te.mockAPI.On("PublishWebSocketEvent", "google_connect", map[string]interface{}{"connected": true, "google_client_id": "randomstring.apps.googleusercontent.com"}, &mattermostModel.WebsocketBroadcast{OmitUsers: map[string]bool(nil), UserId: "userId1", ChannelId: "", TeamId: "", ConnectionId: "", OmitConnectionId: "", ContainsSanitizedData: false, ContainsSensitiveData: false, ReliableClusterSend: false, BroadcastHooks: []string(nil), BroadcastHookArgs: []map[string]interface{}(nil)}).Times(1) }, }, @@ -880,10 +880,10 @@ func TestCompleteConnectUserToGoogle(t *testing.T) { te := SetupTestEnvironment(t) defer te.Cleanup(t) - te.plugin.KVStore = mockKvStore - te.plugin.GoogleClient = mockGoogleClient - te.plugin.oauthConfig = mockOAuth2 - te.plugin.tracker = mockTelemetry + te.plugin.KVStore = mocks.MockKVStore + te.plugin.GoogleClient = mocks.MockGoogleClient + te.plugin.oauthConfig = mocks.MockOAuth2 + te.plugin.tracker = mocks.MockTelemetry te.plugin.initializeAPI() w := httptest.NewRecorder() diff --git a/server/plugin/pluginapi/mutex_mock.go b/server/plugin/pluginapi/mock_mutex.go similarity index 100% rename from server/plugin/pluginapi/mutex_mock.go rename to server/plugin/pluginapi/mock_mutex.go diff --git a/server/plugin/test_utils.go b/server/plugin/test_utils.go index c324928..69a0f0b 100644 --- a/server/plugin/test_utils.go +++ b/server/plugin/test_utils.go @@ -29,6 +29,20 @@ type TestEnvironment struct { mockAPI *plugintest.API } +type MockSetup struct { + MockKVStore *mock_store.MockKVStore + MockGoogleClient *mock_google.MockClientInterface + MockGoogleDrive *mock_google.MockDriveInterface + MockDriveActivity *mock_google.MockDriveActivityInterface + MockGoogleDocs *mock_google.MockDocsInterface + MockGoogleSheets *mock_google.MockSheetsInterface + MockGoogleSlides *mock_google.MockSlidesInterface + MockClusterMutex *mock_pluginapi.MockClusterMutex + MockCluster *mock_pluginapi.MockCluster + MockOAuth2 *mock_oauth2.MockConfigInterface + MockTelemetry *mock_pluginapi.MockTracker +} + func SetupTestEnvironment(t *testing.T) *TestEnvironment { p := Plugin{ BotUserID: "bot_user_id", @@ -61,24 +75,23 @@ func (e *TestEnvironment) ResetMocks(t *testing.T) { } } -// revive:disable-next-line:unexported-return -func GetMockSetup(t *testing.T) (*mock_store.MockKVStore, *mock_google.MockClientInterface, *mock_google.MockDriveInterface, *mock_google.MockDriveActivityInterface, *mock_google.MockDocsInterface, *mock_google.MockSheetsInterface, *mock_google.MockSlidesInterface, *mock_pluginapi.MockClusterMutex, *mock_pluginapi.MockCluster, *mock_oauth2.MockConfigInterface, *mock_pluginapi.MockTracker) { +func GetMockSetup(t *testing.T) *MockSetup { ctrl := gomock.NewController(t) defer ctrl.Finish() - mockKvStore := mock_store.NewMockKVStore(ctrl) - mockGoogleClient := mock_google.NewMockClientInterface(ctrl) - mockGoogleDrive := mock_google.NewMockDriveInterface(ctrl) - mockDriveActivity := mock_google.NewMockDriveActivityInterface(ctrl) - mockGoogleDocs := mock_google.NewMockDocsInterface(ctrl) - mockGoogleSheets := mock_google.NewMockSheetsInterface(ctrl) - mockGoogleSlides := mock_google.NewMockSlidesInterface(ctrl) - mockClusterMutex := mock_pluginapi.NewMockClusterMutex(ctrl) - mockCluster := mock_pluginapi.NewMockCluster(ctrl) - mockOAuth2 := mock_oauth2.NewMockConfigInterface(ctrl) - mockTelemetry := mock_pluginapi.NewMockTracker(ctrl) - - return mockKvStore, mockGoogleClient, mockGoogleDrive, mockDriveActivity, mockGoogleDocs, mockGoogleSheets, mockGoogleSlides, mockClusterMutex, mockCluster, mockOAuth2, mockTelemetry + return &MockSetup{ + MockKVStore: mock_store.NewMockKVStore(ctrl), + MockGoogleClient: mock_google.NewMockClientInterface(ctrl), + MockGoogleDrive: mock_google.NewMockDriveInterface(ctrl), + MockDriveActivity: mock_google.NewMockDriveActivityInterface(ctrl), + MockGoogleDocs: mock_google.NewMockDocsInterface(ctrl), + MockGoogleSheets: mock_google.NewMockSheetsInterface(ctrl), + MockGoogleSlides: mock_google.NewMockSlidesInterface(ctrl), + MockClusterMutex: mock_pluginapi.NewMockClusterMutex(ctrl), + MockCluster: mock_pluginapi.NewMockCluster(ctrl), + MockOAuth2: mock_oauth2.NewMockConfigInterface(ctrl), + MockTelemetry: mock_pluginapi.NewMockTracker(ctrl), + } } func GetSampleChangeList() *drive.ChangeList {