From 321b19e3dbb79d754ea7397647a6454af6738a42 Mon Sep 17 00:00:00 2001 From: Ben Schumacher Date: Wed, 2 Feb 2022 19:45:09 +0100 Subject: [PATCH] [MM-41290] Add Endpoint to complete onboarding (#19435) --- api4/plugin_test.go | 9 +-- api4/system.go | 28 +++++++- api4/system_test.go | 104 +++++++++++++++++++++++++++ app/app_iface.go | 1 + app/onboarding.go | 46 ++++++++++++ app/opentracing/opentracing_layer.go | 22 ++++++ app/plugin.go | 14 +++- i18n/en.json | 8 +++ model/client4.go | 14 ++++ model/onboarding.go | 25 +++++++ services/marketplace/client.go | 33 +++++++++ 11 files changed, 294 insertions(+), 10 deletions(-) create mode 100644 app/onboarding.go create mode 100644 model/onboarding.go diff --git a/api4/plugin_test.go b/api4/plugin_test.go index 88e980dd5f..0fcfc6ddf7 100644 --- a/api4/plugin_test.go +++ b/api4/plugin_test.go @@ -1472,8 +1472,7 @@ func TestInstallMarketplacePlugin(t *testing.T) { *cfg.PluginSettings.MarketplaceURL = testServer.URL }) - // The content of the request is irrelevant. This test only cares about enterprise_plugins. - pRequest := &model.InstallMarketplacePluginRequest{} + pRequest := &model.InstallMarketplacePluginRequest{Id: "testplugin"} manifest, resp, err := client.InstallMarketplacePlugin(pRequest) require.Error(t, err) CheckInternalErrorStatus(t, resp) @@ -1511,8 +1510,7 @@ func TestInstallMarketplacePlugin(t *testing.T) { *l.Features.EnterprisePlugins = false th.App.Srv().SetLicense(l) - // The content of the request is irrelevant. This test only cares about enterprise_plugins. - pRequest := &model.InstallMarketplacePluginRequest{} + pRequest := &model.InstallMarketplacePluginRequest{Id: "testplugin"} manifest, resp, err := client.InstallMarketplacePlugin(pRequest) require.Error(t, err) CheckInternalErrorStatus(t, resp) @@ -1546,8 +1544,7 @@ func TestInstallMarketplacePlugin(t *testing.T) { th.App.Srv().SetLicense(model.NewTestLicense("enterprise_plugins")) - // The content of the request is irrelevant. This test only cares about enterprise_plugins. - pRequest := &model.InstallMarketplacePluginRequest{} + pRequest := &model.InstallMarketplacePluginRequest{Id: "testplugin"} manifest, resp, err := client.InstallMarketplacePlugin(pRequest) require.Error(t, err) CheckInternalErrorStatus(t, resp) diff --git a/api4/system.go b/api4/system.go index 6c2c7a390f..6625c7f0fa 100644 --- a/api4/system.go +++ b/api4/system.go @@ -67,8 +67,8 @@ func (api *API) InitSystem() { api.BaseRoutes.APIRoot.Handle("/warn_metrics/trial-license-ack/{warn_metric_id:[A-Za-z0-9-_]+}", api.APIHandler(requestTrialLicenseAndAckWarnMetric)).Methods("POST") api.BaseRoutes.System.Handle("/notices/{team_id:[A-Za-z0-9]+}", api.APISessionRequired(getProductNotices)).Methods("GET") api.BaseRoutes.System.Handle("/notices/view", api.APISessionRequired(updateViewedProductNotices)).Methods("PUT") - api.BaseRoutes.System.Handle("/support_packet", api.APISessionRequired(generateSupportPacket)).Methods("GET") + api.BaseRoutes.System.Handle("/onboarding/complete", api.APIHandler(completeOnboarding)).Methods("POST") } func generateSupportPacket(c *Context, w http.ResponseWriter, r *http.Request) { @@ -880,3 +880,29 @@ func updateViewedProductNotices(c *Context, w http.ResponseWriter, r *http.Reque auditRec.Success() ReturnStatusOK(w) } + +func completeOnboarding(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) { + c.Err = model.NewAppError("completeOnboarding", "app.system.complete_onboarding_request.no_first_user", nil, "", http.StatusForbidden) + return + } + + auditRec := c.MakeAuditRecord("completeOnboarding", audit.Fail) + defer c.LogAuditRec(auditRec) + + onboardingRequest, err := model.CompleteOnboardingRequestFromReader(r.Body) + if err != nil { + c.Err = model.NewAppError("completeOnboarding", "app.system.complete_onboarding_request.app_error", nil, err.Error(), http.StatusBadRequest) + return + } + auditRec.AddMeta("install_plugin", onboardingRequest.InstallPlugins) + + appErr := c.App.CompleteOnboarding(onboardingRequest) + if appErr != nil { + c.Err = appErr + return + } + + auditRec.Success() + ReturnStatusOK(w) +} diff --git a/api4/system_test.go b/api4/system_test.go index 8520f00087..aab21fa46b 100644 --- a/api4/system_test.go +++ b/api4/system_test.go @@ -5,11 +5,14 @@ package api4 import ( "bytes" + "encoding/base64" + "encoding/json" "fmt" "io/ioutil" "net/http" "net/http/httptest" "os" + "path/filepath" "strconv" "strings" "testing" @@ -20,6 +23,7 @@ import ( "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/shared/mlog" + "github.com/mattermost/mattermost-server/v6/utils/fileutils" ) func TestGetPing(t *testing.T) { @@ -778,3 +782,103 @@ func TestPushNotificationAck(t *testing.T) { assert.NotNil(t, resp.Body) }) } + +func TestCompleteOnboarding(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + path, _ := fileutils.FindDir("tests") + signatureFilename := "testplugin2.tar.gz.sig" + signatureFileReader, err := os.Open(filepath.Join(path, signatureFilename)) + require.NoError(t, err) + sigFile, err := ioutil.ReadAll(signatureFileReader) + require.NoError(t, err) + pluginSignature := base64.StdEncoding.EncodeToString(sigFile) + + tarData, err := ioutil.ReadFile(filepath.Join(path, "testplugin2.tar.gz")) + require.NoError(t, err) + pluginServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + res.WriteHeader(http.StatusOK) + res.Write(tarData) + })) + defer pluginServer.Close() + + samplePlugins := []*model.MarketplacePlugin{{ + BaseMarketplacePlugin: &model.BaseMarketplacePlugin{ + HomepageURL: "https://example.com/mattermost/mattermost-plugin-nps", + IconData: "https://example.com/icon.svg", + DownloadURL: pluginServer.URL, + Manifest: &model.Manifest{ + Id: "testplugin2", + Name: "testplugin2", + Description: "a second plugin", + Version: "1.2.3", + MinServerVersion: "", + }, + Signature: pluginSignature, + }, + InstalledVersion: "", + }} + + marketplaceServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + res.WriteHeader(http.StatusOK) + var data []byte + data, err = json.Marshal(samplePlugins) + require.NoError(t, err) + res.Write(data) + })) + defer marketplaceServer.Close() + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.Enable = true + *cfg.PluginSettings.EnableMarketplace = false + *cfg.PluginSettings.EnableRemoteMarketplace = true + *cfg.PluginSettings.MarketplaceURL = marketplaceServer.URL + *cfg.PluginSettings.AllowInsecureDownloadURL = true + }) + + key, err := os.Open(filepath.Join(path, "development-private-key.asc")) + require.NoError(t, err) + appErr := th.App.AddPublicKey("pub_key", key) + require.Nil(t, appErr) + + t.Cleanup(func() { + appErr = th.App.DeletePublicKey("pub_key") + require.Nil(t, appErr) + }) + + req := &model.CompleteOnboardingRequest{ + InstallPlugins: []string{"testplugin2"}, + } + + t.Run("as a regular user", func(t *testing.T) { + resp, err := th.Client.CompleteOnboarding(req) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + }) + + t.Run("as a system admin", func(t *testing.T) { + resp, err := th.SystemAdminClient.CompleteOnboarding(req) + require.NoError(t, err) + CheckOKStatus(t, resp) + + t.Cleanup(func() { + resp, err = th.SystemAdminClient.RemovePlugin("testplugin2") + require.NoError(t, err) + CheckOKStatus(t, resp) + }) + + installedPlugins, resp, err := th.SystemAdminClient.GetPlugins() + require.NoError(t, err) + CheckOKStatus(t, resp) + + found := false + for _, p := range installedPlugins.Active { + if p.Id == "testplugin2" { + found = true + } + } + + require.True(t, found, "testplugin2 should have been installed and enabled") + }) +} diff --git a/app/app_iface.go b/app/app_iface.go index 434dc4f7c7..4e834f9af0 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -438,6 +438,7 @@ type AppIface interface { CompareAndDeletePluginKey(pluginID string, key string, oldValue []byte) (bool, *model.AppError) CompareAndSetPluginKey(pluginID string, key string, oldValue, newValue []byte) (bool, *model.AppError) CompleteOAuth(c *request.Context, service string, body io.ReadCloser, teamID string, props map[string]string, tokenUser *model.User) (*model.User, *model.AppError) + CompleteOnboarding(request *model.CompleteOnboardingRequest) *model.AppError CompleteSwitchWithOAuth(service string, userData io.Reader, email string, tokenUser *model.User) (*model.User, *model.AppError) Compliance() einterfaces.ComplianceInterface Config() *model.Config diff --git a/app/onboarding.go b/app/onboarding.go new file mode 100644 index 0000000000..0cb19b103a --- /dev/null +++ b/app/onboarding.go @@ -0,0 +1,46 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "net/http" + "sync" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/shared/mlog" +) + +func (a *App) CompleteOnboarding(request *model.CompleteOnboardingRequest) *model.AppError { + var wg sync.WaitGroup + + if !*a.Config().PluginSettings.Enable { + return model.NewAppError("completeOnboarding", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) + } + + for _, id := range request.InstallPlugins { + wg.Add(1) + + go func(id string) { + defer wg.Done() + installRequest := &model.InstallMarketplacePluginRequest{ + Id: id, + } + _, appErr := a.Channels().InstallMarketplacePlugin(installRequest) + if appErr != nil { + mlog.Error("Failed to install plugin for onboarding", mlog.String("id", id), mlog.Err(appErr)) + return + } + + appErr = a.Channels().enablePlugin(id) + if appErr != nil { + mlog.Error("Failed to enable plugin for onboarding", mlog.String("id", id), mlog.Err(appErr)) + return + } + }(id) + } + + wg.Wait() + + return nil +} diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index ea68edecc9..242e3cc80e 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -1671,6 +1671,28 @@ func (a *OpenTracingAppLayer) CompleteOAuth(c *request.Context, service string, return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) CompleteOnboarding(request *model.CompleteOnboardingRequest) *model.AppError { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CompleteOnboarding") + + a.ctx = newCtx + a.app.Srv().Store.SetContext(newCtx) + defer func() { + a.app.Srv().Store.SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0 := a.app.CompleteOnboarding(request) + + if resultVar0 != nil { + span.LogFields(spanlog.Error(resultVar0)) + ext.Error.Set(span, true) + } + + return resultVar0 +} + func (a *OpenTracingAppLayer) CompleteSwitchWithOAuth(service string, userData io.Reader, email string, tokenUser *model.User) (*model.User, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CompleteSwitchWithOAuth") diff --git a/app/plugin.go b/app/plugin.go index ca3ad16bc8..abbfd99d56 100644 --- a/app/plugin.go +++ b/app/plugin.go @@ -538,6 +538,8 @@ func (a *App) GetMarketplacePlugins(filter *model.MarketplacePluginFilter) ([]*m } // getPrepackagedPlugin returns a pre-packaged plugin. +// +// If version is empty, the first matching plugin is returned. func (ch *Channels) getPrepackagedPlugin(pluginID, version string) (*plugin.PrepackagedPlugin, *model.AppError) { pluginsEnvironment := ch.GetPluginsEnvironment() if pluginsEnvironment == nil { @@ -546,7 +548,7 @@ func (ch *Channels) getPrepackagedPlugin(pluginID, version string) (*plugin.Prep prepackagedPlugins := pluginsEnvironment.PrepackagedPlugins() for _, p := range prepackagedPlugins { - if p.Manifest.Id == pluginID && p.Manifest.Version == version { + if p.Manifest.Id == pluginID && (version == "" || p.Manifest.Version == version) { return p, nil } } @@ -555,6 +557,8 @@ func (ch *Channels) getPrepackagedPlugin(pluginID, version string) (*plugin.Prep } // getRemoteMarketplacePlugin returns plugin from marketplace-server. +// +// If version is empty, the latest compatible version is used. func (ch *Channels) getRemoteMarketplacePlugin(pluginID, version string) (*model.BaseMarketplacePlugin, *model.AppError) { marketplaceClient, err := marketplace.NewClient( *ch.srv.Config().PluginSettings.MarketplaceURL, @@ -566,9 +570,13 @@ func (ch *Channels) getRemoteMarketplacePlugin(pluginID, version string) (*model filter := ch.getBaseMarketplaceFilter() filter.PluginId = pluginID - filter.ReturnAllVersions = true - plugin, err := marketplaceClient.GetPlugin(filter, version) + var plugin *model.BaseMarketplacePlugin + if version != "" { + plugin, err = marketplaceClient.GetPlugin(filter, version) + } else { + plugin, err = marketplaceClient.GetLatestPlugin(filter) + } if err != nil { return nil, model.NewAppError("GetMarketplacePlugin", "app.plugin.marketplace_plugins.not_found.app_error", nil, err.Error(), http.StatusInternalServerError) } diff --git a/i18n/en.json b/i18n/en.json index d929d05f13..2a39d828c2 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -6075,6 +6075,14 @@ "id": "app.submit_interactive_dialog.json_error", "translation": "Encountered an error encoding JSON for the interactive dialog." }, + { + "id": "app.system.complete_onboarding_request.app_error", + "translation": "Failed to decode the complete onboarding request." + }, + { + "id": "app.system.complete_onboarding_request.no_first_user", + "translation": "Onboarding can only be completed by a System Administrator." + }, { "id": "app.system.get.app_error", "translation": "We encountered an error finding the system properties." diff --git a/model/client4.go b/model/client4.go index e896808827..b1a3cbea5a 100644 --- a/model/client4.go +++ b/model/client4.go @@ -7432,6 +7432,20 @@ func (c *Client4) MarkNoticesViewed(ids []string) (*Response, error) { return BuildResponse(r), nil } +func (c *Client4) CompleteOnboarding(request *CompleteOnboardingRequest) (*Response, error) { + buf, err := json.Marshal(request) + if err != nil { + return nil, NewAppError("CompleteOnboarding", "api.marshal_error", nil, err.Error(), http.StatusInternalServerError) + } + r, err := c.DoAPIPost(c.systemRoute()+"/onboarding/complete", string(buf)) + if err != nil { + return BuildResponse(r), err + } + defer closeBody(r) + + return BuildResponse(r), nil +} + // CreateUpload creates a new upload session. func (c *Client4) CreateUpload(us *UploadSession) (*UploadSession, *Response, error) { buf, err := json.Marshal(us) diff --git a/model/onboarding.go b/model/onboarding.go new file mode 100644 index 0000000000..9c83e37642 --- /dev/null +++ b/model/onboarding.go @@ -0,0 +1,25 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "encoding/json" + "io" +) + +// CompleteOnboardingRequest describes parameters of the requested plugin. +type CompleteOnboardingRequest struct { + InstallPlugins []string `json:"install_plugins"` // InstallPlugins is a list of plugins to be installed +} + +// CompleteOnboardingRequest decodes a json-encoded request from the given io.Reader. +func CompleteOnboardingRequestFromReader(reader io.Reader) (*CompleteOnboardingRequest, error) { + var r *CompleteOnboardingRequest + err := json.NewDecoder(reader).Decode(&r) + if err != nil { + return nil, err + } + + return r, nil +} diff --git a/services/marketplace/client.go b/services/marketplace/client.go index d119851c81..36c1b7f02a 100644 --- a/services/marketplace/client.go +++ b/services/marketplace/client.go @@ -66,6 +66,16 @@ func (c *Client) GetPlugins(request *model.MarketplacePluginFilter) ([]*model.Ba } func (c *Client) GetPlugin(filter *model.MarketplacePluginFilter, pluginVersion string) (*model.BaseMarketplacePlugin, error) { + filter.ReturnAllVersions = true + + if filter.PluginId == "" { + return nil, errors.New("missing pluginID") + } + + if pluginVersion == "" { + return nil, errors.New("missing pluginVersion") + } + plugins, err := c.GetPlugins(filter) if err != nil { return nil, err @@ -78,6 +88,29 @@ func (c *Client) GetPlugin(filter *model.MarketplacePluginFilter, pluginVersion return nil, errors.New("plugin not found") } +func (c *Client) GetLatestPlugin(filter *model.MarketplacePluginFilter) (*model.BaseMarketplacePlugin, error) { + filter.ReturnAllVersions = false + + if filter.PluginId == "" { + return nil, errors.New("no pluginID provided") + } + + plugins, err := c.GetPlugins(filter) + if err != nil { + return nil, err + } + + if len(plugins) == 0 { + return nil, errors.New("plugin not found") + } + + if len(plugins) > 1 { + return nil, errors.Errorf("unexpectedly more then one plugin was returned from the marketplace") + } + + return plugins[0], nil +} + // closeBody ensures the Body of an http.Response is properly closed. func closeBody(r *http.Response) { if r.Body != nil {