From e85d34163c94d9d37a7d86f3c4343454187a7ccc Mon Sep 17 00:00:00 2001 From: Ibrahim Serdar Acikgoz Date: Thu, 27 Jun 2024 15:27:57 +0200 Subject: [PATCH] Add support packet metadata (#27465) --- server/channels/app/plugin_api.go | 9 + server/public/model/metadata.go | 122 ++++++++++++ server/public/model/metadata_test.go | 176 ++++++++++++++++++ server/public/plugin/api.go | 7 + .../plugin/api_timer_layer_generated.go | 7 + server/public/plugin/client_rpc_generated.go | 30 +++ server/public/plugin/plugintest/api.go | 30 +++ 7 files changed, 381 insertions(+) create mode 100644 server/public/model/metadata.go create mode 100644 server/public/model/metadata_test.go diff --git a/server/channels/app/plugin_api.go b/server/channels/app/plugin_api.go index 8daf00e4e7..801153d571 100644 --- a/server/channels/app/plugin_api.go +++ b/server/channels/app/plugin_api.go @@ -1343,3 +1343,12 @@ func (api *PluginAPI) InviteRemoteToChannel(channelID string, remoteID, userID s func (api *PluginAPI) UninviteRemoteFromChannel(channelID string, remoteID string) error { return api.app.UninviteRemoteFromChannel(channelID, remoteID) } + +func (api *PluginAPI) GenerateSupportMetadata(pluginMeta map[string]any) (*model.Metadata, error) { + md, err := model.GeneratePluginMetadata(api.manifest, api.GetLicense(), api.GetTelemetryId(), pluginMeta) + if err != nil { + return nil, err + } + + return md, nil +} diff --git a/server/public/model/metadata.go b/server/public/model/metadata.go new file mode 100644 index 0000000000..a0487c018d --- /dev/null +++ b/server/public/model/metadata.go @@ -0,0 +1,122 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "encoding/json" + "fmt" + + "github.com/blang/semver/v4" +) + +type MetadataType string + +const ( + CurrentMetadataVersion int = 1 + ServerMetadata MetadataType = "mattermost-support-package" + PluginMetadata MetadataType = "plugin-support-package" +) + +type Metadata struct { + // Required Fields + Version int `json:"version"` + Type MetadataType `json:"type"` + GeneratedAt int64 `json:"generated_at"` + ServerVersion string `json:"server_version"` + ServerID string `json:"server_id"` + LicenseID string `json:"license_id"` + CustomerID string `json:"customer_id"` + // Optional Fields + Extras map[string]any `json:"extras"` +} + +func (md *Metadata) Validate() error { + if md.Version < 1 { + return fmt.Errorf("metadata version should be greater than 1") + } + + if md.Type != ServerMetadata && md.Type != PluginMetadata { + return fmt.Errorf("unrecognized metadata type: %s", md.Type) + } + + if _, err := semver.ParseTolerant(md.ServerVersion); err != nil { + return fmt.Errorf("could not parse server version: %w", err) + } + + if !IsValidId(md.ServerID) { + return fmt.Errorf("server id is not a valid id %q", md.ServerID) + } + + if !IsValidId(md.LicenseID) && md.LicenseID != "" { + return fmt.Errorf("license id is not a valid id %q", md.LicenseID) + } + + if !IsValidId(md.CustomerID) && md.CustomerID != "" { + return fmt.Errorf("customer id is not a valid id %q", md.CustomerID) + } + + return nil +} + +func ParseMetadata(b []byte) (*Metadata, error) { + v := struct { + Version int `json:"version"` + }{} + + err := json.Unmarshal(b, &v) + if err != nil { + return nil, err + } + + switch v.Version { + case 1: + var md Metadata + err = json.Unmarshal(b, &md) + if err != nil { + return nil, err + } + + err = md.Validate() + if err != nil { + return nil, err + } + + return &md, nil + default: + return nil, fmt.Errorf("unsupported metadata version: %d", v.Version) + } +} + +// GeneratePluginMetadata is a utility function to generate a plugin metadata for support packets. +// It will construct it from the manifest, license and so on. The plugin_id and plugin_version will be +// used from the manifest. +func GeneratePluginMetadata(manifest *Manifest, license *License, serverID string, pluginMeta map[string]any) (*Metadata, error) { + if pluginMeta == nil { + pluginMeta = make(map[string]any) + } + + // we override the plugin_id and version fields from the manifest + pluginMeta["plugin_id"] = manifest.Id + pluginMeta["plugin_version"] = manifest.Version + + md := Metadata{ + Version: CurrentMetadataVersion, + Type: PluginMetadata, + GeneratedAt: GetMillis(), + ServerVersion: CurrentVersion, + ServerID: serverID, + Extras: pluginMeta, + } + + if license != nil { + md.LicenseID = license.Id + md.CustomerID = license.Customer.Id + } + + if err := md.Validate(); err != nil { + return nil, fmt.Errorf("invalid metadata: %w", err) + } + + return &md, nil +} diff --git a/server/public/model/metadata_test.go b/server/public/model/metadata_test.go new file mode 100644 index 0000000000..0e78c0a1d3 --- /dev/null +++ b/server/public/model/metadata_test.go @@ -0,0 +1,176 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestValidate(t *testing.T) { + tests := []struct { + name string + metadata Metadata + expectErr bool + }{ + { + name: "Valid Metadata", + metadata: Metadata{ + Version: 1, + Type: ServerMetadata, + GeneratedAt: 1622569200, + ServerVersion: "5.33.3", + ServerID: NewId(), + LicenseID: NewId(), + CustomerID: NewId(), + Extras: map[string]interface{}{"key": "value"}, + }, + expectErr: false, + }, + { + name: "Valid Metadata without license", + metadata: Metadata{ + Version: 1, + Type: ServerMetadata, + GeneratedAt: 1622569200, + ServerVersion: "5.33.3", + ServerID: NewId(), + Extras: map[string]interface{}{"key": "value"}, + }, + expectErr: false, + }, + { + name: "Invalid Version", + metadata: Metadata{ + Version: 0, + Type: ServerMetadata, + GeneratedAt: 1622569200, + ServerVersion: "5.33.3", + ServerID: NewId(), + LicenseID: NewId(), + CustomerID: NewId(), + }, + expectErr: true, + }, + { + name: "Invalid Type", + metadata: Metadata{ + Version: 1, + Type: "invalid-type", + GeneratedAt: 1622569200, + ServerVersion: "5.33.3", + ServerID: NewId(), + LicenseID: NewId(), + CustomerID: NewId(), + }, + expectErr: true, + }, + { + name: "Invalid Server Version", + metadata: Metadata{ + Version: 1, + Type: ServerMetadata, + GeneratedAt: 1622569200, + ServerVersion: "invalid-version", + ServerID: "valid-server-id", + LicenseID: "valid-license-id", + CustomerID: "valid-customer-id", + }, + expectErr: true, + }, + { + name: "Invalid Server ID", + metadata: Metadata{ + Version: 1, + Type: ServerMetadata, + GeneratedAt: 1622569200, + ServerVersion: "5.33.3", + ServerID: "", + LicenseID: NewId(), + CustomerID: NewId(), + }, + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.metadata.Validate() + if tt.expectErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestParseMetadata(t *testing.T) { + validMetadataJSON := `{ + "version": 1, + "type": "mattermost-support-package", + "generated_at": 1622569200, + "server_version": "5.33.3", + "server_id": "8fqk9rti13fmpxdd5934a3xsxh", + "license_id": "3g3pqn8in3brzjkozcn1kdidgr", + "customer_id": "74cmws7gf3ykpj31car7zahsny", + "extras": {"key": "value"} + }` + + invalidVersionJSON := `{ + "version": 0, + "type": "mattermost-support-package", + "generated_at": 1622569200, + "server_version": "5.33.3", + "server_id": "8fqk9rti13fmpxdd5934a3xsxh", + "license_id": "3g3pqn8in3brzjkozcn1kdidgr", + "customer_id": "74cmws7gf3ykpj31car7zahsny", + }` + + unsupportedVersionJSON := `{ + "version": 2, + "type": "mattermost-support-package", + "generated_at": 1622569200, + "server_version": "5.33.3", + "server_id": "8fqk9rti13fmpxdd5934a3xsxh", + "license_id": "3g3pqn8in3brzjkozcn1kdidgr", + "customer_id": "74cmws7gf3ykpj31car7zahsny", + }` + + tests := []struct { + name string + jsonData string + expectErr bool + }{ + { + name: "Valid Metadata JSON", + jsonData: validMetadataJSON, + expectErr: false, + }, + { + name: "Invalid Version in JSON", + jsonData: invalidVersionJSON, + expectErr: true, + }, + { + name: "Unsupported Version in JSON", + jsonData: unsupportedVersionJSON, + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + md, err := ParseMetadata([]byte(tt.jsonData)) + if tt.expectErr { + require.Error(t, err) + require.Nil(t, md) + } else { + require.NoError(t, err) + require.NotNil(t, md) + } + }) + } +} diff --git a/server/public/plugin/api.go b/server/public/plugin/api.go index bc540024d8..173c878099 100644 --- a/server/public/plugin/api.go +++ b/server/public/plugin/api.go @@ -1306,6 +1306,13 @@ type API interface { // @tag User // Minimum server version: 9.8 UpdateUserRoles(userID, newRoles string) (*model.User, *model.AppError) + + // GenerateSupportMetadata generates the metadata for a support packet. + // pluginMeta should contain the values that plugin wants to insert into a standard support packet metadata. + // + // @tag Metadata + // Minimum server version: 9.10 + GenerateSupportMetadata(pluginMeta map[string]any) (*model.Metadata, error) } var handshake = plugin.HandshakeConfig{ diff --git a/server/public/plugin/api_timer_layer_generated.go b/server/public/plugin/api_timer_layer_generated.go index 2643adb8a0..11cfcd9769 100644 --- a/server/public/plugin/api_timer_layer_generated.go +++ b/server/public/plugin/api_timer_layer_generated.go @@ -1378,3 +1378,10 @@ func (api *apiTimerLayer) UpdateUserRoles(userID, newRoles string) (*model.User, api.recordTime(startTime, "UpdateUserRoles", _returnsB == nil) return _returnsA, _returnsB } + +func (api *apiTimerLayer) GenerateSupportMetadata(pluginMeta map[string]any) (*model.Metadata, error) { + startTime := timePkg.Now() + _returnsA, _returnsB := api.apiImpl.GenerateSupportMetadata(pluginMeta) + api.recordTime(startTime, "GenerateSupportMetadata", _returnsB == nil) + return _returnsA, _returnsB +} diff --git a/server/public/plugin/client_rpc_generated.go b/server/public/plugin/client_rpc_generated.go index a2fa59c9de..0591d5f70f 100644 --- a/server/public/plugin/client_rpc_generated.go +++ b/server/public/plugin/client_rpc_generated.go @@ -6630,3 +6630,33 @@ func (s *apiRPCServer) UpdateUserRoles(args *Z_UpdateUserRolesArgs, returns *Z_U } return nil } + +type Z_GenerateSupportMetadataArgs struct { + A map[string]any +} + +type Z_GenerateSupportMetadataReturns struct { + A *model.Metadata + B error +} + +func (g *apiRPCClient) GenerateSupportMetadata(pluginMeta map[string]any) (*model.Metadata, error) { + _args := &Z_GenerateSupportMetadataArgs{pluginMeta} + _returns := &Z_GenerateSupportMetadataReturns{} + if err := g.client.Call("Plugin.GenerateSupportMetadata", _args, _returns); err != nil { + log.Printf("RPC call to GenerateSupportMetadata API failed: %s", err.Error()) + } + return _returns.A, _returns.B +} + +func (s *apiRPCServer) GenerateSupportMetadata(args *Z_GenerateSupportMetadataArgs, returns *Z_GenerateSupportMetadataReturns) error { + if hook, ok := s.impl.(interface { + GenerateSupportMetadata(pluginMeta map[string]any) (*model.Metadata, error) + }); ok { + returns.A, returns.B = hook.GenerateSupportMetadata(args.A) + returns.B = encodableError(returns.B) + } else { + return encodableError(fmt.Errorf("API GenerateSupportMetadata called but not implemented.")) + } + return nil +} diff --git a/server/public/plugin/plugintest/api.go b/server/public/plugin/plugintest/api.go index 07f212fdae..91077eafdb 100644 --- a/server/public/plugin/plugintest/api.go +++ b/server/public/plugin/plugintest/api.go @@ -891,6 +891,36 @@ func (_m *API) ExtendSessionExpiry(sessionID string, newExpiry int64) *model.App return r0 } +// GenerateSupportMetadata provides a mock function with given fields: pluginMeta +func (_m *API) GenerateSupportMetadata(pluginMeta map[string]interface{}) (*model.Metadata, error) { + ret := _m.Called(pluginMeta) + + if len(ret) == 0 { + panic("no return value specified for GenerateSupportMetadata") + } + + var r0 *model.Metadata + var r1 error + if rf, ok := ret.Get(0).(func(map[string]interface{}) (*model.Metadata, error)); ok { + return rf(pluginMeta) + } + if rf, ok := ret.Get(0).(func(map[string]interface{}) *model.Metadata); ok { + r0 = rf(pluginMeta) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Metadata) + } + } + + if rf, ok := ret.Get(1).(func(map[string]interface{}) error); ok { + r1 = rf(pluginMeta) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetBot provides a mock function with given fields: botUserId, includeDeleted func (_m *API) GetBot(botUserId string, includeDeleted bool) (*model.Bot, *model.AppError) { ret := _m.Called(botUserId, includeDeleted)