diff --git a/.circleci/config.yml b/.circleci/config.yml index b6710d62..397a732c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3,7 +3,7 @@ version: 2.1 executors: build: docker: - - image: circleci/golang:1.13 + - image: circleci/golang:1.14 deploy: docker: - image: circleci/node:8.10 diff --git a/Makefile b/Makefile index b5dd86c4..a241af75 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,7 @@ BUILD_HASH_SHORT = $(shell git rev-parse --short HEAD) LDFLAGS += -X "github.com/mattermost/mattermost-marketplace/internal/api.buildTag=$(BUILD_TAG)" LDFLAGS += -X "github.com/mattermost/mattermost-marketplace/internal/api.buildHash=$(BUILD_HASH)" LDFLAGS += -X "github.com/mattermost/mattermost-marketplace/internal/api.buildHashShort=$(BUILD_HASH_SHORT)" +LDFLAGS += -X "main.upstreamURL=$(BUILD_UPSTREAM_URL)" SLS_STAGE ?= "dev" ## Checks the code style, tests, builds and bundles. diff --git a/README.md b/README.md index e459dcca..3c5d3f52 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,20 @@ Running all tests: $ make test ``` +### Proxying upstream + +The marketplace can be configured to proxy to an upstream marketplace, overlaying any locally defined plugins on top of the remote service. Invoke the server with the appropriate flag: + +``` +go run ./cmd/marketplace server --upstream https://api.integrations.mattermost.com +``` + +To compile this flag into the binary such as when building the lambda function, define the appropriate environment variable: +``` +export BUILD_UPSTREAM_URL=https://api.integrations.mattermost.com +make build-lambda +``` + ### Updating plugins.json To fetch all new release from GitHub, run diff --git a/cmd/lambda/main.go b/cmd/lambda/main.go index 9c650de3..a9b0ec58 100644 --- a/cmd/lambda/main.go +++ b/cmd/lambda/main.go @@ -13,6 +13,11 @@ import ( "github.com/mattermost/mattermost-marketplace/internal/store" ) +var ( + // upstreamURL may be compiled into the binary by defining $BUILD_UPSTREAM_URL + upstreamURL = "" +) + var logger *logrus.Logger func main() { @@ -22,7 +27,7 @@ func main() { } } -func newStatikStore(statikPath string, logger logrus.FieldLogger) (*store.Store, error) { +func newStatikStore(statikPath string, logger logrus.FieldLogger) (*store.StaticStore, error) { statikFS, err := fs.New() if err != nil { return nil, errors.Wrap(err, "failed to open statik fileystem") @@ -34,7 +39,7 @@ func newStatikStore(statikPath string, logger logrus.FieldLogger) (*store.Store, } defer database.Close() - statikStore, err := store.New(database, logger) + statikStore, err := store.NewStaticFromReader(database, logger) if err != nil { return nil, errors.Wrap(err, "failed to initialize store") } @@ -45,14 +50,25 @@ func newStatikStore(statikPath string, logger logrus.FieldLogger) (*store.Store, func listenAndServe() error { logger = logrus.New() - statikStore, err := newStatikStore("/plugins.json", logger) + var apiStore store.Store + var err error + apiStore, err = newStatikStore("/plugins.json", logger) if err != nil { return err } + if upstreamURL != "" { + upstreamStore, err := store.NewProxy(upstreamURL, logger) + if err != nil { + return errors.Wrap(err, "failed to initialize upstream store") + } + + apiStore = store.NewMerged(logger, apiStore, upstreamStore) + } + router := mux.NewRouter() api.Register(router, &api.Context{ - Store: statikStore, + Store: apiStore, Logger: logger, }) diff --git a/cmd/marketplace/server.go b/cmd/marketplace/server.go index fd8d6680..3dd43cf1 100644 --- a/cmd/marketplace/server.go +++ b/cmd/marketplace/server.go @@ -18,13 +18,19 @@ import ( "github.com/mattermost/mattermost-marketplace/internal/store" ) -var instanceID string +var ( + instanceID string + + // upstreamURL may be compiled into the binary by defining $BUILD_UPSTREAM_URL + upstreamURL string +) func init() { instanceID = model.NewId() serverCmd.PersistentFlags().String("database", "plugins.json", "The read-only JSON file backing the server.") serverCmd.PersistentFlags().String("listen", ":8085", "The interface and port on which to listen.") + serverCmd.PersistentFlags().String("upstream", upstreamURL, "An upstream marketplace server with which to merge results.") serverCmd.PersistentFlags().Bool("debug", false, "Whether to output debug logs.") } @@ -46,18 +52,33 @@ var serverCmd = &cobra.Command{ } defer databaseFile.Close() - fileStore, err := store.New(databaseFile, logger) + var apiStore store.Store + + apiStore, err = store.NewStaticFromReader(databaseFile, logger) if err != nil { return errors.Wrap(err, "failed to initialize store") } + upstreamURL, _ := command.Flags().GetString("upstream") + if upstreamURL != "" { + var upstreamStore *store.Proxy + upstreamStore, err = store.NewProxy(upstreamURL, logger) + if err != nil { + return errors.Wrap(err, "failed to initialize upstream store") + } + + logger.WithField("upstream", upstreamURL).Info("Proxying to upstream marketplace") + + apiStore = store.NewMerged(logger, apiStore, upstreamStore) + } + logger := logger.WithField("instance", instanceID) logger.Info("Starting Plugin Marketplace") router := mux.NewRouter() api.Register(router, &api.Context{ - Store: fileStore, + Store: apiStore, Logger: logger, }) diff --git a/go.mod b/go.mod index 9dc06fa8..6f759f80 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/mattermost/mattermost-marketplace -go 1.12 +go 1.14 require ( github.com/akrylysov/algnhsa v0.0.0-20190319020909-05b3d192e9a7 diff --git a/internal/api/plugins_test.go b/internal/api/plugins_test.go index 0c3b2c48..d3432a4a 100644 --- a/internal/api/plugins_test.go +++ b/internal/api/plugins_test.go @@ -23,7 +23,7 @@ func setupAPI(t *testing.T, plugins []*model.Plugin) (*api.Client, func()) { data, err := json.Marshal(plugins) require.NoError(t, err) - store, err := store.New(bytes.NewReader(data), logger) + store, err := store.NewStaticFromReader(bytes.NewReader(data), logger) require.NoError(t, err) router := mux.NewRouter() diff --git a/internal/store/merged.go b/internal/store/merged.go new file mode 100644 index 00000000..fc50f658 --- /dev/null +++ b/internal/store/merged.go @@ -0,0 +1,56 @@ +package store + +import ( + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + + "github.com/mattermost/mattermost-marketplace/internal/model" +) + +// Merged is a store that merges the results of multiple stores together. +// +// If a plugin is present in multiple stores, the later version is preferred. If a plugin with +// the same version is present in multiple stores, the one from the later store (as initialized) +// is preferred. +type Merged struct { + stores []Store + logger logrus.FieldLogger +} + +// NewMerged creates a new instance of the merged store. +func NewMerged(logger logrus.FieldLogger, stores ...Store) *Merged { + return &Merged{ + stores: stores, + logger: logger, + } +} + +// GetPlugins fetches the given page of plugins. The first page is 0. +func (store *Merged) GetPlugins(pluginFilter *model.PluginFilter) ([]*model.Plugin, error) { + // Short-circuit if only one store is configured. + if len(store.stores) == 1 { + return store.stores[0].GetPlugins(pluginFilter) + } + + plugins := []*model.Plugin{} + for i, store := range store.stores { + storePlugins, err := store.GetPlugins(&model.PluginFilter{ + Page: 0, + PerPage: model.AllPerPage, + Filter: pluginFilter.Filter, + ServerVersion: pluginFilter.ServerVersion, + }) + if err != nil { + return nil, errors.Wrapf(err, "failed to query store %d", i) + } + + plugins = append(plugins, storePlugins...) + } + + staticStore, err := NewStatic(plugins, store.logger) + if err != nil { + return nil, errors.Wrap(err, "failed to initialize static store") + } + + return staticStore.GetPlugins(pluginFilter) +} diff --git a/internal/store/merged_test.go b/internal/store/merged_test.go new file mode 100644 index 00000000..1906160e --- /dev/null +++ b/internal/store/merged_test.go @@ -0,0 +1,269 @@ +package store + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + mattermostModel "github.com/mattermost/mattermost-server/v5/model" + + "github.com/mattermost/mattermost-marketplace/internal/model" + "github.com/mattermost/mattermost-marketplace/internal/testlib" +) + +func TestMerged(t *testing.T) { + plugin1V1 := &model.Plugin{ + HomepageURL: "https://github.com/mattermost/mattermost-plugin-demo", + IconData: "icon-data.svg", + DownloadURL: "https://github.com/mattermost/mattermost-plugin-demo/releases/download/v0.1.0/com.mattermost.demo-plugin-0.1.0.tar.gz", + Manifest: &mattermostModel.Manifest{ + Id: "mattermost-plugin-demo", + Name: "mattermost-plugin-demo", + Version: "0.1.0", + }, + Signature: "signature1", + } + plugin1V2 := &model.Plugin{ + HomepageURL: "https://github.com/mattermost/mattermost-plugin-demo", + IconData: "icon-data.svg", + DownloadURL: "https://github.com/mattermost/mattermost-plugin-demo/releases/download/v0.2.0/com.mattermost.demo-plugin-0.2.0.tar.gz", + Manifest: &mattermostModel.Manifest{ + Id: "mattermost-plugin-demo", + Name: "mattermost-plugin-demo", + Version: "0.2.0", + }, + Signature: "signature1", + } + plugin1V3 := &model.Plugin{ + HomepageURL: "https://github.com/mattermost/mattermost-plugin-demo", + IconData: "icon-data.svg", + DownloadURL: "https://github.com/mattermost/mattermost-plugin-demo/releases/download/v0.3.0/com.mattermost.demo-plugin-0.3.0.tar.gz", + Manifest: &mattermostModel.Manifest{ + Id: "mattermost-plugin-demo", + Name: "mattermost-plugin-demo", + Version: "0.3.0", + }, + Signature: "signature1", + } + plugin2V1 := &model.Plugin{ + HomepageURL: "https://github.com/mattermost/mattermost-plugin-starter-template", + IconData: "icon-data2.svg", + DownloadURL: "https://github.com/mattermost/mattermost-plugin-starter-template/releases/download/v0.1.0/com.mattermost.plugin-starter-template-0.1.0.tar.gz", + Manifest: &mattermostModel.Manifest{ + Id: "mattermost-plugin-starter-template", + Name: "mattermost-plugin-starter-template", + Version: "0.1.0", + }, + Signature: "signature2", + } + plugin3V1 := &model.Plugin{ + HomepageURL: "https://github.com/matterpoll/matterpoll", + IconData: "icon-data3.svg", + DownloadURL: "https://github.com/matterpoll/matterpoll/releases/download/v1.1.0/com.github.matterpoll.matterpoll-1.1.0.tar.gz", + Manifest: &mattermostModel.Manifest{ + Id: "matterpoll", + Name: "matterpoll", + Version: "1.1.0", + }, + Signature: "signature3", + } + + plugin3V2 := &model.Plugin{ + HomepageURL: "https://github.com/matterpoll/matterpoll", + IconData: "icon-data3.svg", + DownloadURL: "https://github.com/matterpoll/matterpoll/releases/download/v1.2.0/com.github.matterpoll.matterpoll-1.2.0.tar.gz", + Manifest: &mattermostModel.Manifest{ + Id: "matterpoll", + Name: "matterpoll", + Version: "1.2.0", + }, + Signature: "signature3", + } + + plugin3V3 := &model.Plugin{ + HomepageURL: "https://github.com/matterpoll/matterpoll", + IconData: "icon-data3.svg", + DownloadURL: "https://github.com/matterpoll/matterpoll/releases/download/v1.3.0/com.github.matterpoll.matterpoll-1.3.0.tar.gz", + Manifest: &mattermostModel.Manifest{ + Id: "matterpoll", + Name: "matterpoll", + Version: "1.3.0", + }, + Signature: "signature3", + } + + plugin4V1 := &model.Plugin{ + HomepageURL: "fake_plugin", + IconData: "icon-data3.svg", + DownloadURL: "fake_plugin.tar.gz", + Manifest: &mattermostModel.Manifest{ + Id: "fake_plugin", + Name: "Zfake_plugin", + Version: "1.2.4", + }, + Signature: "signature3", + } + + plugin4V1Later := &model.Plugin{ + HomepageURL: "fake_plugin", + IconData: "icon-data3-later.svg", + DownloadURL: "fake_plugin.tar.gz", + Manifest: &mattermostModel.Manifest{ + Id: "fake_plugin", + Name: "Zfake_plugin", + Version: "1.2.4", + }, + Signature: "signature3", + } + + t.Run("no stores", func(t *testing.T) { + logger := testlib.MakeLogger(t) + + store := NewMerged(logger) + require.NotNil(t, store) + + plugins, err := store.GetPlugins(&model.PluginFilter{ + Page: 0, + PerPage: model.AllPerPage, + }) + require.NoError(t, err) + assert.Empty(t, plugins) + }) + + t.Run("single empty store", func(t *testing.T) { + logger := testlib.MakeLogger(t) + + static1, err := NewStatic([]*model.Plugin{}, logger) + require.NoError(t, err) + + store := NewMerged(logger, static1) + assert.NoError(t, err) + require.NotNil(t, store) + + plugins, err := store.GetPlugins(&model.PluginFilter{ + Page: 0, + PerPage: model.AllPerPage, + }) + require.NoError(t, err) + assert.Empty(t, plugins) + }) + + t.Run("multiple empty stores", func(t *testing.T) { + logger := testlib.MakeLogger(t) + + static1, err := NewStatic([]*model.Plugin{}, logger) + require.NoError(t, err) + static2, err := NewStatic([]*model.Plugin{}, logger) + require.NoError(t, err) + + store := NewMerged(logger, static1, static2) + assert.NoError(t, err) + require.NotNil(t, store) + + plugins, err := store.GetPlugins(&model.PluginFilter{ + Page: 0, + PerPage: model.AllPerPage, + }) + require.NoError(t, err) + assert.Empty(t, plugins) + }) + + t.Run("single, populated store", func(t *testing.T) { + logger := testlib.MakeLogger(t) + + static1, err := NewStatic([]*model.Plugin{plugin1V3, plugin2V1, plugin3V3, plugin4V1}, logger) + require.NoError(t, err) + + store := NewMerged(logger, static1) + assert.NoError(t, err) + require.NotNil(t, store) + + plugins, err := store.GetPlugins(&model.PluginFilter{ + Page: 0, + PerPage: model.AllPerPage, + }) + require.NoError(t, err) + assert.Equal(t, []*model.Plugin{ + plugin1V3, + plugin2V1, + plugin3V3, + plugin4V1, + }, plugins) + }) + + t.Run("conflict-free merge", func(t *testing.T) { + logger := testlib.MakeLogger(t) + + static1, err := NewStatic([]*model.Plugin{plugin1V1, plugin1V2, plugin1V3}, logger) + require.NoError(t, err) + static2, err := NewStatic([]*model.Plugin{plugin2V1, plugin3V1, plugin3V2, plugin3V3, plugin4V1}, logger) + require.NoError(t, err) + + store := NewMerged(logger, static1, static2) + assert.NoError(t, err) + require.NotNil(t, store) + + plugins, err := store.GetPlugins(&model.PluginFilter{ + Page: 0, + PerPage: model.AllPerPage, + }) + require.NoError(t, err) + assert.Equal(t, []*model.Plugin{ + plugin1V3, + plugin2V1, + plugin3V3, + plugin4V1, + }, plugins) + }) + + t.Run("newer versions win across stores", func(t *testing.T) { + logger := testlib.MakeLogger(t) + + static1, err := NewStatic([]*model.Plugin{plugin1V1, plugin2V1, plugin3V1, plugin4V1}, logger) + require.NoError(t, err) + static2, err := NewStatic([]*model.Plugin{plugin1V3, plugin2V1, plugin3V3, plugin4V1}, logger) + require.NoError(t, err) + + store := NewMerged(logger, static1, static2) + assert.NoError(t, err) + require.NotNil(t, store) + + plugins, err := store.GetPlugins(&model.PluginFilter{ + Page: 0, + PerPage: model.AllPerPage, + }) + require.NoError(t, err) + assert.Equal(t, []*model.Plugin{ + plugin1V3, + plugin2V1, + plugin3V3, + plugin4V1, + }, plugins) + }) + + t.Run("later stores win across versions", func(t *testing.T) { + logger := testlib.MakeLogger(t) + + static1, err := NewStatic([]*model.Plugin{plugin4V1}, logger) + require.NoError(t, err) + static2, err := NewStatic([]*model.Plugin{plugin4V1Later}, logger) + require.NoError(t, err) + static3, err := NewStatic([]*model.Plugin{plugin1V3}, logger) + require.NoError(t, err) + + store := NewMerged(logger, static1, static2, static3) + assert.NoError(t, err) + require.NotNil(t, store) + + plugins, err := store.GetPlugins(&model.PluginFilter{ + Page: 0, + PerPage: model.AllPerPage, + }) + require.NoError(t, err) + assert.Equal(t, []*model.Plugin{ + plugin1V3, + plugin4V1Later, + }, plugins) + }) +} diff --git a/internal/store/plugin_test.go b/internal/store/plugin_test.go deleted file mode 100644 index dd9614ea..00000000 --- a/internal/store/plugin_test.go +++ /dev/null @@ -1,216 +0,0 @@ -package store - -import ( - "bytes" - "encoding/json" - "testing" - - mattermostModel "github.com/mattermost/mattermost-server/v5/model" - "github.com/stretchr/testify/require" - - "github.com/mattermost/mattermost-marketplace/internal/model" - "github.com/mattermost/mattermost-marketplace/internal/testlib" -) - -func TestPlugins(t *testing.T) { - demoPluginV1Min514 := &model.Plugin{ - HomepageURL: "https://github.com/mattermost/mattermost-plugin-demo", - IconData: "icon-data.svg", - DownloadURL: "https://github.com/mattermost/mattermost-plugin-demo/releases/download/v0.1.0/com.mattermost.demo-plugin-0.1.0.tar.gz", - Manifest: &mattermostModel.Manifest{ - Id: "com.mattermost.demo-plugin", - Name: "Demo Plugin", - Description: "This plugin demonstrates the capabilities of a Mattermost plugin.", - Version: "0.1.0", - MinServerVersion: "5.14.0", - }, - Signature: "signature1", - } - - demoPluginV2Min515 := &model.Plugin{ - HomepageURL: "https://github.com/mattermost/mattermost-plugin-demo", - IconData: "icon-data.svg", - DownloadURL: "https://github.com/mattermost/mattermost-plugin-demo/releases/download/v0.2.0/com.mattermost.demo-plugin-0.2.0.tar.gz", - Manifest: &mattermostModel.Manifest{ - Id: "com.mattermost.demo-plugin", - Name: "Demo Plugin", - Description: "This plugin demonstrates the capabilities of a Mattermost plugin.", - Version: "0.2.0", - MinServerVersion: "5.15.0", - }, - Signature: "signature1", - } - - starterPluginV1Min515 := &model.Plugin{ - HomepageURL: "https://github.com/mattermost/mattermost-plugin-starter-template", - IconData: "icon-data2.svg", - DownloadURL: "https://github.com/mattermost/mattermost-plugin-starter-template/releases/download/v0.1.0/com.mattermost.plugin-starter-template-0.1.0.tar.gz", - Manifest: &mattermostModel.Manifest{ - Id: "com.mattermost.plugin-starter-template", - Name: "Plugin Starter Template", - Description: "This plugin serves as a starting point for writing a Mattermost plugin.", - Version: "0.1.0", - MinServerVersion: "5.15.0", - }, - Signature: "signature2", - } - - data, err := json.Marshal([]*model.Plugin{ - demoPluginV1Min514, - demoPluginV2Min515, - starterPluginV1Min515, - }) - require.NoError(t, err) - - logger := testlib.MakeLogger(t) - sqlStore, err := New(bytes.NewReader(data), logger) - require.NoError(t, err) - - t.Run("page 0, per page 0", func(t *testing.T) { - actualPlugins, err := sqlStore.GetPlugins(&model.PluginFilter{ - Page: 0, - PerPage: 0, - Filter: "", - }) - require.NoError(t, err) - require.Empty(t, actualPlugins) - }) - - t.Run("page 0, per page 1", func(t *testing.T) { - actualPlugins, err := sqlStore.GetPlugins(&model.PluginFilter{ - Page: 0, - PerPage: 1, - Filter: "", - }) - require.NoError(t, err) - require.Equal(t, []*model.Plugin{demoPluginV2Min515}, actualPlugins) - }) - - t.Run("page 0, per page 10", func(t *testing.T) { - actualPlugins, err := sqlStore.GetPlugins(&model.PluginFilter{ - Page: 0, - PerPage: 10, - Filter: "", - }) - require.NoError(t, err) - require.Equal(t, []*model.Plugin{demoPluginV2Min515, starterPluginV1Min515}, actualPlugins) - }) - - t.Run("page 0, per page 1", func(t *testing.T) { - actualPlugins, err := sqlStore.GetPlugins(&model.PluginFilter{ - Page: 0, - PerPage: 1, - Filter: "", - }) - require.NoError(t, err) - require.Equal(t, []*model.Plugin{demoPluginV2Min515}, actualPlugins) - }) - - t.Run("page 0, per page 10", func(t *testing.T) { - actualPlugins, err := sqlStore.GetPlugins(&model.PluginFilter{ - Page: 0, - PerPage: 10, - Filter: "", - }) - require.NoError(t, err) - require.Equal(t, []*model.Plugin{demoPluginV2Min515, starterPluginV1Min515}, actualPlugins) - }) - - t.Run("default paging", func(t *testing.T) { - actualPlugins, err := sqlStore.GetPlugins(&model.PluginFilter{PerPage: model.AllPerPage, - Filter: "", - }) - require.NoError(t, err) - require.Equal(t, []*model.Plugin{demoPluginV2Min515, starterPluginV1Min515}, actualPlugins) - }) - - t.Run("filter spaces", func(t *testing.T) { - actualPlugins, err := sqlStore.GetPlugins(&model.PluginFilter{PerPage: model.AllPerPage, - Filter: " ", - }) - require.NoError(t, err) - require.Equal(t, []*model.Plugin{demoPluginV2Min515, starterPluginV1Min515}, actualPlugins) - }) - - t.Run("id match, exact", func(t *testing.T) { - actualPlugins, err := sqlStore.GetPlugins(&model.PluginFilter{PerPage: model.AllPerPage, - Filter: "com.mattermost.demo-plugin", - }) - require.NoError(t, err) - require.Equal(t, []*model.Plugin{demoPluginV2Min515}, actualPlugins) - }) - - t.Run("id match, case-insensitive", func(t *testing.T) { - actualPlugins, err := sqlStore.GetPlugins(&model.PluginFilter{PerPage: model.AllPerPage, - Filter: "com.mattermost.demo-PLUGIN", - }) - require.NoError(t, err) - require.Equal(t, []*model.Plugin{demoPluginV2Min515}, actualPlugins) - }) - - t.Run("name match, exact", func(t *testing.T) { - actualPlugins, err := sqlStore.GetPlugins(&model.PluginFilter{PerPage: model.AllPerPage, - Filter: "Plugin Starter Template", - }) - require.NoError(t, err) - require.Equal(t, []*model.Plugin{starterPluginV1Min515}, actualPlugins) - }) - - t.Run("name match, partial", func(t *testing.T) { - actualPlugins, err := sqlStore.GetPlugins(&model.PluginFilter{PerPage: model.AllPerPage, - Filter: "Starter", - }) - require.NoError(t, err) - require.Equal(t, []*model.Plugin{starterPluginV1Min515}, actualPlugins) - }) - - t.Run("name match, case-insensitive", func(t *testing.T) { - actualPlugins, err := sqlStore.GetPlugins(&model.PluginFilter{PerPage: model.AllPerPage, - Filter: "TEMPLATE", - }) - require.NoError(t, err) - require.Equal(t, []*model.Plugin{starterPluginV1Min515}, actualPlugins) - }) - - t.Run("description match, partial", func(t *testing.T) { - actualPlugins, err := sqlStore.GetPlugins(&model.PluginFilter{PerPage: model.AllPerPage, - Filter: "capabilities", - }) - require.NoError(t, err) - require.Equal(t, []*model.Plugin{demoPluginV2Min515}, actualPlugins) - }) - - t.Run("description match, case-insensitive, multiple matches", func(t *testing.T) { - actualPlugins, err := sqlStore.GetPlugins(&model.PluginFilter{PerPage: model.AllPerPage, - Filter: "MATTERMOST", - }) - require.NoError(t, err) - require.Equal(t, []*model.Plugin{demoPluginV2Min515, starterPluginV1Min515}, actualPlugins) - }) - - t.Run("plugins that satisfy 5.15", func(t *testing.T) { - actualPlugins, err := sqlStore.GetPlugins(&model.PluginFilter{PerPage: model.AllPerPage, - Filter: "MATTERMOST", - ServerVersion: "5.15.0", - }) - require.NoError(t, err) - require.Equal(t, []*model.Plugin{demoPluginV2Min515, starterPluginV1Min515}, actualPlugins) - }) - - t.Run("plugins that satisfy 5.14", func(t *testing.T) { - actualPlugins, err := sqlStore.GetPlugins(&model.PluginFilter{PerPage: model.AllPerPage, - Filter: "MATTERMOST", - ServerVersion: "5.14.0", - }) - require.NoError(t, err) - require.Equal(t, []*model.Plugin{demoPluginV1Min514}, actualPlugins) - }) - - t.Run("with a server version that does not satisfy any plugin", func(t *testing.T) { - actualPlugins, err := sqlStore.GetPlugins(&model.PluginFilter{PerPage: model.AllPerPage, - ServerVersion: "5.13.0", - }) - require.NoError(t, err) - require.Nil(t, actualPlugins) - }) -} diff --git a/internal/store/proxy.go b/internal/store/proxy.go new file mode 100644 index 00000000..29e48497 --- /dev/null +++ b/internal/store/proxy.go @@ -0,0 +1,40 @@ +package store + +import ( + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + + "github.com/mattermost/mattermost-marketplace/internal/api" + "github.com/mattermost/mattermost-marketplace/internal/model" +) + +// Proxy is a store that fetches its result from some remote marketplace server. +type Proxy struct { + marketplaceURL string + logger logrus.FieldLogger +} + +// NewProxy creates a new instance of a proxy store. +func NewProxy(marketplaceURL string, logger logrus.FieldLogger) (*Proxy, error) { + return &Proxy{ + marketplaceURL: marketplaceURL, + logger: logger.WithField("marketplace_url", marketplaceURL), + }, nil +} + +// GetPlugins fetches the given page of plugins. The first page is 0. +func (store *Proxy) GetPlugins(pluginFilter *model.PluginFilter) ([]*model.Plugin, error) { + client := api.NewClient(store.marketplaceURL) + + plugins, err := client.GetPlugins(&api.GetPluginsRequest{ + Page: pluginFilter.Page, + PerPage: pluginFilter.PerPage, + Filter: pluginFilter.Filter, + ServerVersion: pluginFilter.ServerVersion, + }) + if err != nil { + return nil, errors.Wrap(err, "failed to reach upstream store") + } + + return plugins, nil +} diff --git a/internal/store/proxy_test.go b/internal/store/proxy_test.go new file mode 100644 index 00000000..cc399e21 --- /dev/null +++ b/internal/store/proxy_test.go @@ -0,0 +1,78 @@ +package store + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + mattermostModel "github.com/mattermost/mattermost-server/v5/model" + + "github.com/mattermost/mattermost-marketplace/internal/model" + "github.com/mattermost/mattermost-marketplace/internal/testlib" +) + +func TestProxy(t *testing.T) { + t.Run("empty stream", func(t *testing.T) { + logger := testlib.MakeLogger(t) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(ts.Close) + + proxyStore, err := NewProxy(ts.URL, logger) + require.NoError(t, err) + + plugins, err := proxyStore.GetPlugins(&model.PluginFilter{ + PerPage: model.AllPerPage, + }) + require.NoError(t, err) + require.Empty(t, plugins) + }) + + t.Run("empty stream with error", func(t *testing.T) { + logger := testlib.MakeLogger(t) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(`{"invalid":`)) + require.NoError(t, err) + })) + t.Cleanup(ts.Close) + + proxyStore, err := NewProxy(ts.URL, logger) + require.NoError(t, err) + + plugins, err := proxyStore.GetPlugins(&model.PluginFilter{ + PerPage: model.AllPerPage, + }) + require.Error(t, err) + require.Empty(t, plugins) + }) + + t.Run("valid stream", func(t *testing.T) { + logger := testlib.MakeLogger(t) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(`[{"homepage_url":"https://github.com/mattermost/mattermost-plugin-demo","icon_data":"icon-data.svg","download_url":"https://github.com/mattermost/mattermost-plugin-demo/releases/download/v0.1.0/com.mattermost.demo-plugin-0.1.0.tar.gz","signature":"signature1", "release_notes_url":"https://github.com/mattermost/mattermost-plugin-demo/releases/v0.1.0","manifest":{}}]`)) + require.NoError(t, err) + })) + t.Cleanup(ts.Close) + + proxyStore, err := NewProxy(ts.URL, logger) + require.NoError(t, err) + + plugins, err := proxyStore.GetPlugins(&model.PluginFilter{ + PerPage: model.AllPerPage, + }) + require.NoError(t, err) + require.Equal(t, []*model.Plugin{{ + HomepageURL: "https://github.com/mattermost/mattermost-plugin-demo", + IconData: "icon-data.svg", + DownloadURL: "https://github.com/mattermost/mattermost-plugin-demo/releases/download/v0.1.0/com.mattermost.demo-plugin-0.1.0.tar.gz", + Signature: "signature1", + ReleaseNotesURL: "https://github.com/mattermost/mattermost-plugin-demo/releases/v0.1.0", + Manifest: &mattermostModel.Manifest{}, + }}, plugins) + }) +} diff --git a/internal/store/plugin.go b/internal/store/static.go similarity index 63% rename from internal/store/plugin.go rename to internal/store/static.go index cfd634b3..e59d24cf 100644 --- a/internal/store/plugin.go +++ b/internal/store/static.go @@ -1,15 +1,60 @@ package store import ( + "io" "sort" "strings" "github.com/blang/semver" "github.com/pkg/errors" + "github.com/sirupsen/logrus" "github.com/mattermost/mattermost-marketplace/internal/model" ) +// StaticStore provides access to a store backed by a static set of plugins. +type StaticStore struct { + plugins []*model.Plugin + logger logrus.FieldLogger +} + +// NewStatic constructs a new instance of a static store, parsing the plugins from the given reader. +func NewStaticFromReader(reader io.Reader, logger logrus.FieldLogger) (*StaticStore, error) { + plugins, err := model.PluginsFromReader(reader) + if err != nil { + return nil, errors.Wrap(err, "failed to parse stream") + } + + return NewStatic(plugins, logger) +} + +// NewStatic constructs a new instance of a static store using the given plugins. +func NewStatic(plugins []*model.Plugin, logger logrus.FieldLogger) (*StaticStore, error) { + if err := validatePlugins(plugins); err != nil { + return nil, errors.Wrap(err, "failed to validate plugins") + } + + return &StaticStore{ + plugins, + logger, + }, nil +} + +func validatePlugins(plugins []*model.Plugin) error { + for _, plugin := range plugins { + err := plugin.Manifest.IsValid() + if err != nil { + return errors.Wrapf(err, "invalid manifest for plugin %s", plugin.Manifest.Id) + } + + if plugin.Manifest.Version == "" { + return errors.Errorf("missing version in manifest for plugin%s", plugin.Manifest.Id) + } + } + + return nil +} + func pluginMatchesFilter(plugin *model.Plugin, filter string) bool { filter = strings.ToLower(filter) if strings.ToLower(plugin.Manifest.Id) == filter { @@ -28,7 +73,7 @@ func pluginMatchesFilter(plugin *model.Plugin, filter string) bool { } // GetPlugins fetches the given page of plugins. The first page is 0. -func (store *Store) GetPlugins(pluginFilter *model.PluginFilter) ([]*model.Plugin, error) { +func (store *StaticStore) GetPlugins(pluginFilter *model.PluginFilter) ([]*model.Plugin, error) { if pluginFilter.PerPage == 0 { return nil, nil } @@ -69,7 +114,7 @@ func (store *Store) GetPlugins(pluginFilter *model.PluginFilter) ([]*model.Plugi } // getPlugins returns all plugins compatible with the given server version, sorted by name ascending. -func (store *Store) getPlugins(serverVersion string) ([]*model.Plugin, error) { +func (store *StaticStore) getPlugins(serverVersion string) ([]*model.Plugin, error) { var result []*model.Plugin plugins := map[string]*model.Plugin{} @@ -96,7 +141,10 @@ func (store *Store) getPlugins(serverVersion string) ([]*model.Plugin, error) { } storePluginVersion := semver.MustParse(storePlugin.Manifest.Version) - if storePluginVersion.GT(lastSeenPluginVersion) { + + // Replace the existing plugin if this version is newer, or if it's the same but + // appears later in the list. + if storePluginVersion.GTE(lastSeenPluginVersion) { plugins[storePlugin.Manifest.Id] = storePlugin } } diff --git a/internal/store/static_test.go b/internal/store/static_test.go new file mode 100644 index 00000000..6737eb89 --- /dev/null +++ b/internal/store/static_test.go @@ -0,0 +1,398 @@ +package store + +import ( + "bytes" + "encoding/json" + "testing" + + mattermostModel "github.com/mattermost/mattermost-server/v5/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost-marketplace/internal/model" + "github.com/mattermost/mattermost-marketplace/internal/testlib" +) + +func TestNewStatic(t *testing.T) { + t.Run("empty stream", func(t *testing.T) { + logger := testlib.MakeLogger(t) + store, err := NewStatic([]*model.Plugin{}, logger) + assert.NoError(t, err) + require.NotNil(t, store) + assert.Empty(t, store.plugins) + }) + + t.Run("missing manifest id", func(t *testing.T) { + logger := testlib.MakeLogger(t) + store, err := NewStatic([]*model.Plugin{ + { + HomepageURL: "https://github.com/mattermost/mattermost-plugin-demo", + IconData: "icon-data.svg", + DownloadURL: "https://github.com/mattermost/mattermost-plugin-demo/releases/download/v0.1.0/com.mattermost.demo-plugin-0.1.0.tar.gz", + Signature: "c2lnbmF0dXJl", + ReleaseNotesURL: "https://github.com/mattermost/mattermost-plugin-demo/releases/v0.1.0", + Manifest: &mattermostModel.Manifest{}, + }, + { + HomepageURL: "https://github.com/mattermost/mattermost-plugin-starter-template", + DownloadURL: "https://github.com/mattermost/mattermost-plugin-starter-template/releases/download/v0.1.0/com.mattermost.plugin-starter-template-0.1.0.tar.gz", + Signature: "signature2", + ReleaseNotesURL: "https://github.com/mattermost/mattermost-plugin-starter-template/releases/v0.1.0", + Manifest: &mattermostModel.Manifest{}, + }, + }, logger) + assert.Error(t, err) + assert.Nil(t, store) + }) + + t.Run("missing manifest version", func(t *testing.T) { + logger := testlib.MakeLogger(t) + store, err := NewStatic([]*model.Plugin{ + { + HomepageURL: "https://github.com/mattermost/mattermost-plugin-demo", + IconData: "icon-data.svg", + DownloadURL: "https://github.com/mattermost/mattermost-plugin-demo/releases/download/v0.1.0/com.mattermost.demo-plugin-0.1.0.tar.gz", + Signature: "c2lnbmF0dXJl", + ReleaseNotesURL: "https://github.com/mattermost/mattermost-plugin-demo/releases/v0.1.0", + Manifest: &mattermostModel.Manifest{ + Id: "test", + }, + }, + { + HomepageURL: "https://github.com/mattermost/mattermost-plugin-starter-template", + DownloadURL: "https://github.com/mattermost/mattermost-plugin-starter-template/releases/download/v0.1.0/com.mattermost.plugin-starter-template-0.1.0.tar.gz", + Signature: "signature2", + ReleaseNotesURL: "https://github.com/mattermost/mattermost-plugin-starter-template/releases/v0.1.0", + Manifest: &mattermostModel.Manifest{ + Id: "test", + }, + }, + }, logger) + assert.Error(t, err) + assert.Nil(t, store) + }) + + t.Run("missing min_server_version version is valid", func(t *testing.T) { + logger := testlib.MakeLogger(t) + store, err := NewStatic([]*model.Plugin{ + { + HomepageURL: "https://github.com/mattermost/mattermost-plugin-demo", + IconData: "icon-data.svg", + DownloadURL: "https://github.com/mattermost/mattermost-plugin-demo/releases/download/v0.1.0/com.mattermost.demo-plugin-0.1.0.tar.gz", + Signature: "c2lnbmF0dXJl", + ReleaseNotesURL: "https://github.com/mattermost/mattermost-plugin-demo/releases/v0.1.0", + Manifest: &mattermostModel.Manifest{ + Id: "test", + Version: "0.1.0", + }, + }, + { + HomepageURL: "https://github.com/mattermost/mattermost-plugin-starter-template", + DownloadURL: "https://github.com/mattermost/mattermost-plugin-starter-template/releases/download/v0.1.0/com.mattermost.plugin-starter-template-0.1.0.tar.gz", + Signature: "signature2", + ReleaseNotesURL: "https://github.com/mattermost/mattermost-plugin-starter-template/releases/v0.1.0", + Manifest: &mattermostModel.Manifest{ + Id: "test", + Version: "0.1.0", + }, + }, + }, logger) + assert.NoError(t, err) + assert.NotNil(t, store) + }) + + t.Run("valid stream", func(t *testing.T) { + logger := testlib.MakeLogger(t) + store, err := NewStatic([]*model.Plugin{ + { + HomepageURL: "https://github.com/mattermost/mattermost-plugin-demo", + IconData: "icon-data.svg", + DownloadURL: "https://github.com/mattermost/mattermost-plugin-demo/releases/download/v0.1.0/com.mattermost.demo-plugin-0.1.0.tar.gz", + Signature: "c2lnbmF0dXJl", + ReleaseNotesURL: "https://github.com/mattermost/mattermost-plugin-demo/releases/v0.1.0", + Manifest: &mattermostModel.Manifest{ + Id: "test", + Version: "0.1.0", + MinServerVersion: "5.23.0", + }, + }, + { + HomepageURL: "https://github.com/mattermost/mattermost-plugin-starter-template", + DownloadURL: "https://github.com/mattermost/mattermost-plugin-starter-template/releases/download/v0.1.0/com.mattermost.plugin-starter-template-0.1.0.tar.gz", + Signature: "signature2", + ReleaseNotesURL: "https://github.com/mattermost/mattermost-plugin-starter-template/releases/v0.1.0", + Manifest: &mattermostModel.Manifest{ + Id: "test", + Version: "0.1.0", + MinServerVersion: "5.23.0", + }, + }, + }, logger) + assert.NoError(t, err) + assert.NotNil(t, store) + }) +} + +func TestNewStaticFromReader(t *testing.T) { + t.Run("empty stream", func(t *testing.T) { + logger := testlib.MakeLogger(t) + store, err := NewStaticFromReader(bytes.NewReader([]byte{}), logger) + assert.NoError(t, err) + require.NotNil(t, store) + assert.Empty(t, store.plugins) + }) + + t.Run("invalid stream", func(t *testing.T) { + logger := testlib.MakeLogger(t) + store, err := NewStaticFromReader(bytes.NewReader([]byte(`{"invalid":`)), logger) + assert.EqualError(t, err, "failed to parse stream: unexpected EOF") + assert.Nil(t, store) + }) + + t.Run("missing manifest id", func(t *testing.T) { + logger := testlib.MakeLogger(t) + store, err := NewStaticFromReader(bytes.NewReader([]byte(`[{"HomepageURL":"https://github.com/mattermost/mattermost-plugin-demo","IconData":"icon-data.svg","DownloadURL":"https://github.com/mattermost/mattermost-plugin-demo/releases/download/v0.1.0/com.mattermost.demo-plugin-0.1.0.tar.gz","Signature":"c2lnbmF0dXJl","ReleaseNotesURL":"https://github.com/mattermost/mattermost-plugin-demo/releases/v0.1.0","Manifest":{}},{"HomepageURL":"https://github.com/mattermost/mattermost-plugin-starter-template","DownloadURL":"https://github.com/mattermost/mattermost-plugin-starter-template/releases/download/v0.1.0/com.mattermost.plugin-starter-template-0.1.0.tar.gz","Signature":"signature2","ReleaseNotesURL":"https://github.com/mattermost/mattermost-plugin-starter-template/releases/v0.1.0","Manifest":{}}]`)), logger) + assert.Error(t, err) + assert.Nil(t, store) + }) + + t.Run("missing manifest version", func(t *testing.T) { + logger := testlib.MakeLogger(t) + store, err := NewStaticFromReader(bytes.NewReader([]byte(`[{"HomepageURL":"https://github.com/mattermost/mattermost-plugin-demo","IconData":"icon-data.svg","DownloadURL":"https://github.com/mattermost/mattermost-plugin-demo/releases/download/v0.1.0/com.mattermost.demo-plugin-0.1.0.tar.gz","Signature":"c2lnbmF0dXJl","ReleaseNotesURL":"https://github.com/mattermost/mattermost-plugin-demo/releases/v0.1.0","Manifest":{"id": "test"}},{"HomepageURL":"https://github.com/mattermost/mattermost-plugin-starter-template","DownloadURL":"https://github.com/mattermost/mattermost-plugin-starter-template/releases/download/v0.1.0/com.mattermost.plugin-starter-template-0.1.0.tar.gz","Signature":"signature2"],"ReleaseNotesURL":"https://github.com/mattermost/mattermost-plugin-starter-template/releases/v0.1.0","Manifest":{"id": "test"}}]`)), logger) + assert.Error(t, err) + assert.Nil(t, store) + }) + + t.Run("missing min_server_version version is valid", func(t *testing.T) { + logger := testlib.MakeLogger(t) + store, err := NewStaticFromReader(bytes.NewReader([]byte(`[{"HomepageURL":"https://github.com/mattermost/mattermost-plugin-demo","IconData":"icon-data.svg","DownloadURL":"https://github.com/mattermost/mattermost-plugin-demo/releases/download/v0.1.0/com.mattermost.demo-plugin-0.1.0.tar.gz","Signature":"c2lnbmF0dXJl","ReleaseNotesURL":"https://github.com/mattermost/mattermost-plugin-demo/releases/v0.1.0","Manifest":{"id": "test", "version": "0.1.0"}},{"HomepageURL":"https://github.com/mattermost/mattermost-plugin-starter-template","DownloadURL":"https://github.com/mattermost/mattermost-plugin-starter-template/releases/download/v0.1.0/com.mattermost.plugin-starter-template-0.1.0.tar.gz","Signature":"signature2","ReleaseNotesURL":"https://github.com/mattermost/mattermost-plugin-starter-template/releases/v0.1.0","Manifest":{"id": "test", "version": "0.1.0"}}]`)), logger) + assert.NoError(t, err) + assert.NotNil(t, store) + }) + + t.Run("valid stream", func(t *testing.T) { + logger := testlib.MakeLogger(t) + store, err := NewStaticFromReader(bytes.NewReader([]byte(`[{"HomepageURL":"https://github.com/mattermost/mattermost-plugin-demo","IconData":"icon-data.svg","DownloadURL":"https://github.com/mattermost/mattermost-plugin-demo/releases/download/v0.1.0/com.mattermost.demo-plugin-0.1.0.tar.gz","Signature":"c2lnbmF0dXJl","ReleaseNotesURL":"https://github.com/mattermost/mattermost-plugin-demo/releases/v0.1.0","Manifest":{"id": "test", "version": "0.1.0", "min_server_version":"5.23.0"}},{"HomepageURL":"https://github.com/mattermost/mattermost-plugin-starter-template","DownloadURL":"https://github.com/mattermost/mattermost-plugin-starter-template/releases/download/v0.1.0/com.mattermost.plugin-starter-template-0.1.0.tar.gz","Signature":"signature2","ReleaseNotesURL":"https://github.com/mattermost/mattermost-plugin-starter-template/releases/v0.1.0","Manifest":{"id": "test", "version": "0.1.0", "min_server_version":"5.23.0"}}]`)), logger) + assert.NoError(t, err) + assert.NotNil(t, store) + }) +} + +func TestStaticGetPlugins(t *testing.T) { + demoPluginV1Min514 := &model.Plugin{ + HomepageURL: "https://github.com/mattermost/mattermost-plugin-demo", + IconData: "icon-data.svg", + DownloadURL: "https://github.com/mattermost/mattermost-plugin-demo/releases/download/v0.1.0/com.mattermost.demo-plugin-0.1.0.tar.gz", + Manifest: &mattermostModel.Manifest{ + Id: "com.mattermost.demo-plugin", + Name: "Demo Plugin", + Description: "This plugin demonstrates the capabilities of a Mattermost plugin.", + Version: "0.1.0", + MinServerVersion: "5.14.0", + }, + Signature: "signature1", + } + + demoPluginV2Min515 := &model.Plugin{ + HomepageURL: "https://github.com/mattermost/mattermost-plugin-demo", + IconData: "icon-data.svg", + DownloadURL: "https://github.com/mattermost/mattermost-plugin-demo/releases/download/v0.2.0/com.mattermost.demo-plugin-0.2.0.tar.gz", + Manifest: &mattermostModel.Manifest{ + Id: "com.mattermost.demo-plugin", + Name: "Demo Plugin", + Description: "This plugin demonstrates the capabilities of a Mattermost plugin.", + Version: "0.2.0", + MinServerVersion: "5.15.0", + }, + Signature: "signature1", + } + + // earlier will never appear, since later instance with same version overrides + starterPluginV1Min515Earlier := &model.Plugin{ + HomepageURL: "https://github.com/mattermost/mattermost-plugin-starter-template-earlier", + IconData: "icon-data2-earlier.svg", + DownloadURL: "https://github.com/mattermost/mattermost-plugin-starter-template-earlier/releases/download/v0.1.0/com.mattermost.plugin-starter-template-0.1.0.tar.gz", + Manifest: &mattermostModel.Manifest{ + Id: "com.mattermost.plugin-starter-template", + Name: "Plugin Starter Template (Earlier)", + Description: "This plugin serves as a starting point for writing a Mattermost plugin.", + Version: "0.1.0", + MinServerVersion: "5.15.0", + }, + Signature: "signature2-earlier", + } + + starterPluginV1Min515 := &model.Plugin{ + HomepageURL: "https://github.com/mattermost/mattermost-plugin-starter-template", + IconData: "icon-data2.svg", + DownloadURL: "https://github.com/mattermost/mattermost-plugin-starter-template/releases/download/v0.1.0/com.mattermost.plugin-starter-template-0.1.0.tar.gz", + Manifest: &mattermostModel.Manifest{ + Id: "com.mattermost.plugin-starter-template", + Name: "Plugin Starter Template", + Description: "This plugin serves as a starting point for writing a Mattermost plugin.", + Version: "0.1.0", + MinServerVersion: "5.15.0", + }, + Signature: "signature2", + } + + data, err := json.Marshal([]*model.Plugin{ + demoPluginV1Min514, + demoPluginV2Min515, + starterPluginV1Min515Earlier, + starterPluginV1Min515, + }) + require.NoError(t, err) + + logger := testlib.MakeLogger(t) + staticStore, err := NewStaticFromReader(bytes.NewReader(data), logger) + require.NoError(t, err) + + t.Run("page 0, per page 0", func(t *testing.T) { + actualPlugins, err := staticStore.GetPlugins(&model.PluginFilter{ + Page: 0, + PerPage: 0, + Filter: "", + }) + require.NoError(t, err) + require.Empty(t, actualPlugins) + }) + + t.Run("page 0, per page 1", func(t *testing.T) { + actualPlugins, err := staticStore.GetPlugins(&model.PluginFilter{ + Page: 0, + PerPage: 1, + Filter: "", + }) + require.NoError(t, err) + require.Equal(t, []*model.Plugin{demoPluginV2Min515}, actualPlugins) + }) + + t.Run("page 0, per page 10", func(t *testing.T) { + actualPlugins, err := staticStore.GetPlugins(&model.PluginFilter{ + Page: 0, + PerPage: 10, + Filter: "", + }) + require.NoError(t, err) + require.Equal(t, []*model.Plugin{demoPluginV2Min515, starterPluginV1Min515}, actualPlugins) + }) + + t.Run("page 0, per page 1", func(t *testing.T) { + actualPlugins, err := staticStore.GetPlugins(&model.PluginFilter{ + Page: 0, + PerPage: 1, + Filter: "", + }) + require.NoError(t, err) + require.Equal(t, []*model.Plugin{demoPluginV2Min515}, actualPlugins) + }) + + t.Run("page 0, per page 10", func(t *testing.T) { + actualPlugins, err := staticStore.GetPlugins(&model.PluginFilter{ + Page: 0, + PerPage: 10, + Filter: "", + }) + require.NoError(t, err) + require.Equal(t, []*model.Plugin{demoPluginV2Min515, starterPluginV1Min515}, actualPlugins) + }) + + t.Run("default paging", func(t *testing.T) { + actualPlugins, err := staticStore.GetPlugins(&model.PluginFilter{PerPage: model.AllPerPage, + Filter: "", + }) + require.NoError(t, err) + require.Equal(t, []*model.Plugin{demoPluginV2Min515, starterPluginV1Min515}, actualPlugins) + }) + + t.Run("filter spaces", func(t *testing.T) { + actualPlugins, err := staticStore.GetPlugins(&model.PluginFilter{PerPage: model.AllPerPage, + Filter: " ", + }) + require.NoError(t, err) + require.Equal(t, []*model.Plugin{demoPluginV2Min515, starterPluginV1Min515}, actualPlugins) + }) + + t.Run("id match, exact", func(t *testing.T) { + actualPlugins, err := staticStore.GetPlugins(&model.PluginFilter{PerPage: model.AllPerPage, + Filter: "com.mattermost.demo-plugin", + }) + require.NoError(t, err) + require.Equal(t, []*model.Plugin{demoPluginV2Min515}, actualPlugins) + }) + + t.Run("id match, case-insensitive", func(t *testing.T) { + actualPlugins, err := staticStore.GetPlugins(&model.PluginFilter{PerPage: model.AllPerPage, + Filter: "com.mattermost.demo-PLUGIN", + }) + require.NoError(t, err) + require.Equal(t, []*model.Plugin{demoPluginV2Min515}, actualPlugins) + }) + + t.Run("name match, exact", func(t *testing.T) { + actualPlugins, err := staticStore.GetPlugins(&model.PluginFilter{PerPage: model.AllPerPage, + Filter: "Plugin Starter Template", + }) + require.NoError(t, err) + require.Equal(t, []*model.Plugin{starterPluginV1Min515}, actualPlugins) + }) + + t.Run("name match, partial", func(t *testing.T) { + actualPlugins, err := staticStore.GetPlugins(&model.PluginFilter{PerPage: model.AllPerPage, + Filter: "Starter", + }) + require.NoError(t, err) + require.Equal(t, []*model.Plugin{starterPluginV1Min515}, actualPlugins) + }) + + t.Run("name match, case-insensitive", func(t *testing.T) { + actualPlugins, err := staticStore.GetPlugins(&model.PluginFilter{PerPage: model.AllPerPage, + Filter: "TEMPLATE", + }) + require.NoError(t, err) + require.Equal(t, []*model.Plugin{starterPluginV1Min515}, actualPlugins) + }) + + t.Run("description match, partial", func(t *testing.T) { + actualPlugins, err := staticStore.GetPlugins(&model.PluginFilter{PerPage: model.AllPerPage, + Filter: "capabilities", + }) + require.NoError(t, err) + require.Equal(t, []*model.Plugin{demoPluginV2Min515}, actualPlugins) + }) + + t.Run("description match, case-insensitive, multiple matches", func(t *testing.T) { + actualPlugins, err := staticStore.GetPlugins(&model.PluginFilter{PerPage: model.AllPerPage, + Filter: "MATTERMOST", + }) + require.NoError(t, err) + require.Equal(t, []*model.Plugin{demoPluginV2Min515, starterPluginV1Min515}, actualPlugins) + }) + + t.Run("plugins that satisfy 5.15", func(t *testing.T) { + actualPlugins, err := staticStore.GetPlugins(&model.PluginFilter{PerPage: model.AllPerPage, + Filter: "MATTERMOST", + ServerVersion: "5.15.0", + }) + require.NoError(t, err) + require.Equal(t, []*model.Plugin{demoPluginV2Min515, starterPluginV1Min515}, actualPlugins) + }) + + t.Run("plugins that satisfy 5.14", func(t *testing.T) { + actualPlugins, err := staticStore.GetPlugins(&model.PluginFilter{PerPage: model.AllPerPage, + Filter: "MATTERMOST", + ServerVersion: "5.14.0", + }) + require.NoError(t, err) + require.Equal(t, []*model.Plugin{demoPluginV1Min514}, actualPlugins) + }) + + t.Run("with a server version that does not satisfy any plugin", func(t *testing.T) { + actualPlugins, err := staticStore.GetPlugins(&model.PluginFilter{PerPage: model.AllPerPage, + ServerVersion: "5.13.0", + }) + require.NoError(t, err) + require.Nil(t, actualPlugins) + }) +} diff --git a/internal/store/store.go b/internal/store/store.go index 579b63b9..491c76f3 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -1,48 +1,8 @@ package store -import ( - "io" +import "github.com/mattermost/mattermost-marketplace/internal/model" - "github.com/pkg/errors" - "github.com/sirupsen/logrus" - - "github.com/mattermost/mattermost-marketplace/internal/model" -) - -// Store provides access to a store backed by the given reader. -type Store struct { - plugins []*model.Plugin - logger logrus.FieldLogger -} - -// New constructs a new instance of Store. -func New(reader io.Reader, logger logrus.FieldLogger) (*Store, error) { - plugins, err := model.PluginsFromReader(reader) - if err != nil { - return nil, errors.Wrap(err, "failed to parse stream") - } - - if err := validatePlugins(plugins); err != nil { - return nil, errors.Wrap(err, "failed to validate plugins") - } - - return &Store{ - plugins, - logger, - }, nil -} - -func validatePlugins(plugins []*model.Plugin) error { - for _, plugin := range plugins { - err := plugin.Manifest.IsValid() - if err != nil { - return errors.Wrapf(err, "invalid manifest for plugin %s", plugin.Manifest.Id) - } - - if plugin.Manifest.Version == "" { - return errors.Errorf("missing version in manifest for plugin%s", plugin.Manifest.Id) - } - } - - return nil +// Store describes the interface to the backing store. +type Store interface { + GetPlugins(filter *model.PluginFilter) ([]*model.Plugin, error) } diff --git a/internal/store/store_test.go b/internal/store/store_test.go deleted file mode 100644 index 9f159055..00000000 --- a/internal/store/store_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package store - -import ( - "bytes" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/mattermost/mattermost-marketplace/internal/testlib" -) - -func TestNew(t *testing.T) { - t.Run("empty stream", func(t *testing.T) { - logger := testlib.MakeLogger(t) - store, err := New(bytes.NewReader([]byte{}), logger) - assert.NoError(t, err) - require.NotNil(t, store) - assert.Empty(t, store.plugins) - }) - - t.Run("invalid stream", func(t *testing.T) { - logger := testlib.MakeLogger(t) - store, err := New(bytes.NewReader([]byte(`{"invalid":`)), logger) - assert.EqualError(t, err, "failed to parse stream: unexpected EOF") - assert.Nil(t, store) - }) - - t.Run("missing manifest id", func(t *testing.T) { - logger := testlib.MakeLogger(t) - store, err := New(bytes.NewReader([]byte(`[{"HomepageURL":"https://github.com/mattermost/mattermost-plugin-demo","IconData":"icon-data.svg","DownloadURL":"https://github.com/mattermost/mattermost-plugin-demo/releases/download/v0.1.0/com.mattermost.demo-plugin-0.1.0.tar.gz","DownloadSignature":"c2lnbmF0dXJl","ReleaseNotesURL":"https://github.com/mattermost/mattermost-plugin-demo/releases/v0.1.0","Manifest":{}},{"HomepageURL":"https://github.com/mattermost/mattermost-plugin-starter-template","DownloadURL":"https://github.com/mattermost/mattermost-plugin-starter-template/releases/download/v0.1.0/com.mattermost.plugin-starter-template-0.1.0.tar.gz","Signatures":[{"signature":"signature2","public_key_hash":"hash2"}],"ReleaseNotesURL":"https://github.com/mattermost/mattermost-plugin-starter-template/releases/v0.1.0","Manifest":{}}]`)), logger) - assert.Error(t, err) - assert.Nil(t, store) - }) - - t.Run("missing manifest version", func(t *testing.T) { - logger := testlib.MakeLogger(t) - store, err := New(bytes.NewReader([]byte(`[{"HomepageURL":"https://github.com/mattermost/mattermost-plugin-demo","IconData":"icon-data.svg","DownloadURL":"https://github.com/mattermost/mattermost-plugin-demo/releases/download/v0.1.0/com.mattermost.demo-plugin-0.1.0.tar.gz","DownloadSignature":"c2lnbmF0dXJl","ReleaseNotesURL":"https://github.com/mattermost/mattermost-plugin-demo/releases/v0.1.0","Manifest":{"id": "test"}},{"HomepageURL":"https://github.com/mattermost/mattermost-plugin-starter-template","DownloadURL":"https://github.com/mattermost/mattermost-plugin-starter-template/releases/download/v0.1.0/com.mattermost.plugin-starter-template-0.1.0.tar.gz","Signatures":[{"signature":"signature2","public_key_hash":"hash2"}],"ReleaseNotesURL":"https://github.com/mattermost/mattermost-plugin-starter-template/releases/v0.1.0","Manifest":{"id": "test"}}]`)), logger) - assert.Error(t, err) - assert.Nil(t, store) - }) - - t.Run("missing min_server_version version is valid", func(t *testing.T) { - logger := testlib.MakeLogger(t) - store, err := New(bytes.NewReader([]byte(`[{"HomepageURL":"https://github.com/mattermost/mattermost-plugin-demo","IconData":"icon-data.svg","DownloadURL":"https://github.com/mattermost/mattermost-plugin-demo/releases/download/v0.1.0/com.mattermost.demo-plugin-0.1.0.tar.gz","DownloadSignature":"c2lnbmF0dXJl","ReleaseNotesURL":"https://github.com/mattermost/mattermost-plugin-demo/releases/v0.1.0","Manifest":{"id": "test", "version": "0.1.0"}},{"HomepageURL":"https://github.com/mattermost/mattermost-plugin-starter-template","DownloadURL":"https://github.com/mattermost/mattermost-plugin-starter-template/releases/download/v0.1.0/com.mattermost.plugin-starter-template-0.1.0.tar.gz","Signatures":[{"signature":"signature2","public_key_hash":"hash2"}],"ReleaseNotesURL":"https://github.com/mattermost/mattermost-plugin-starter-template/releases/v0.1.0","Manifest":{"id": "test", "version": "0.1.0"}}]`)), logger) - assert.NoError(t, err) - assert.NotNil(t, store) - }) - - t.Run("valid stream", func(t *testing.T) { - logger := testlib.MakeLogger(t) - store, err := New(bytes.NewReader([]byte(`[{"HomepageURL":"https://github.com/mattermost/mattermost-plugin-demo","IconData":"icon-data.svg","DownloadURL":"https://github.com/mattermost/mattermost-plugin-demo/releases/download/v0.1.0/com.mattermost.demo-plugin-0.1.0.tar.gz","DownloadSignature":"c2lnbmF0dXJl","ReleaseNotesURL":"https://github.com/mattermost/mattermost-plugin-demo/releases/v0.1.0","Manifest":{"id": "test", "version": "0.1.0"}},{"HomepageURL":"https://github.com/mattermost/mattermost-plugin-starter-template","DownloadURL":"https://github.com/mattermost/mattermost-plugin-starter-template/releases/download/v0.1.0/com.mattermost.plugin-starter-template-0.1.0.tar.gz","Signatures":[{"signature":"signature2","public_key_hash":"hash2"}],"ReleaseNotesURL":"https://github.com/mattermost/mattermost-plugin-starter-template/releases/v0.1.0","Manifest":{"id": "test", "version": "0.1.0"}}]`)), logger) - assert.NoError(t, err) - assert.NotNil(t, store) - }) -}