diff --git a/plugin.json b/plugin.json index 2c44c42..780e938 100644 --- a/plugin.json +++ b/plugin.json @@ -21,10 +21,53 @@ "bundle_path": "webapp/dist/main.js" }, "settings_schema": { - "header": "", - "footer": "", - "settings": [], - "sections": null + "header": "Configure the XMPP bridge connection settings below.", + "footer": "For more information about setting up the XMPP bridge, see the [documentation](https://github.com/mattermost/mattermost-plugin-bridge-xmpp/blob/main/README.md).", + "settings": [ + { + "key": "XMPPServerURL", + "display_name": "XMPP Server URL", + "type": "text", + "help_text": "The URL of the XMPP server to connect to (e.g., xmpp.example.com:5222)", + "placeholder": "xmpp.example.com:5222" + }, + { + "key": "XMPPUsername", + "display_name": "XMPP Username", + "type": "text", + "help_text": "The username for authenticating with the XMPP server", + "placeholder": "bridge@xmpp.example.com" + }, + { + "key": "XMPPPassword", + "display_name": "XMPP Password", + "type": "password", + "help_text": "The password for authenticating with the XMPP server" + }, + { + "key": "EnableSync", + "display_name": "Enable Message Synchronization", + "type": "bool", + "help_text": "When enabled, messages will be synchronized between Mattermost and XMPP", + "default": false + }, + { + "key": "XMPPUsernamePrefix", + "display_name": "XMPP Username Prefix", + "type": "text", + "help_text": "Prefix for XMPP users in Mattermost (e.g., 'xmpp' creates usernames like 'xmpp:user@domain')", + "placeholder": "xmpp", + "default": "xmpp" + }, + { + "key": "XMPPResource", + "display_name": "XMPP Resource", + "type": "text", + "help_text": "XMPP resource identifier for the bridge client", + "placeholder": "mattermost-bridge", + "default": "mattermost-bridge" + } + ] }, "props": { "pluginctl": { diff --git a/server/configuration.go b/server/configuration.go index 05aaf5b..0a89437 100644 --- a/server/configuration.go +++ b/server/configuration.go @@ -1,11 +1,15 @@ package main import ( + "fmt" "reflect" + "strings" "github.com/pkg/errors" ) +const DefaultXMPPUsernamePrefix = "xmpp" + // configuration captures the plugin's external configuration as exposed in the Mattermost server // configuration, as well as values computed from the configuration. Any public fields will be // deserialized from the Mattermost server configuration in OnConfigurationChange. @@ -18,6 +22,12 @@ import ( // If you add non-reference types to your configuration struct, be sure to rewrite Clone as a deep // copy appropriate for your types. type configuration struct { + XMPPServerURL string `json:"xmpp_server_url"` + XMPPUsername string `json:"xmpp_username"` + XMPPPassword string `json:"xmpp_password"` + EnableSync bool `json:"enable_sync"` + XMPPUsernamePrefix string `json:"xmpp_username_prefix"` + XMPPResource string `json:"xmpp_resource"` } // Clone shallow copies the configuration. Your implementation may require a deep copy if @@ -27,6 +37,50 @@ func (c *configuration) Clone() *configuration { return &clone } +// GetXMPPUsernamePrefix returns the configured username prefix, or the default if not set +func (c *configuration) GetXMPPUsernamePrefix() string { + if c.XMPPUsernamePrefix == "" { + return DefaultXMPPUsernamePrefix + } + return c.XMPPUsernamePrefix +} + +// GetXMPPResource returns the configured XMPP resource, or a default if not set +func (c *configuration) GetXMPPResource() string { + if c.XMPPResource == "" { + return "mattermost-bridge" + } + return c.XMPPResource +} + +// IsValid validates the configuration and returns an error if invalid +func (c *configuration) IsValid() error { + if c.EnableSync { + if c.XMPPServerURL == "" { + return fmt.Errorf("XMPP Server URL is required when sync is enabled") + } + if c.XMPPUsername == "" { + return fmt.Errorf("XMPP Username is required when sync is enabled") + } + if c.XMPPPassword == "" { + return fmt.Errorf("XMPP Password is required when sync is enabled") + } + + // Validate server URL format + if !strings.Contains(c.XMPPServerURL, ":") { + return fmt.Errorf("XMPP Server URL must include port (e.g., server.com:5222)") + } + + // Validate username prefix doesn't contain invalid characters + prefix := c.GetXMPPUsernamePrefix() + if strings.ContainsAny(prefix, ":@/\\") { + return fmt.Errorf("XMPP Username Prefix cannot contain special characters (:, @, /, \\)") + } + } + + return nil +} + // getConfiguration retrieves the active configuration under lock, making it safe to use // concurrently. The active configuration may change underneath the client of this method, but // the struct returned by this API call is considered immutable. @@ -77,6 +131,11 @@ func (p *Plugin) OnConfigurationChange() error { return errors.Wrap(err, "failed to load plugin configuration") } + // Validate the configuration + if err := configuration.IsValid(); err != nil { + return errors.Wrap(err, "invalid plugin configuration") + } + p.setConfiguration(configuration) return nil diff --git a/server/plugin.go b/server/plugin.go index 568a6be..a24c8ae 100644 --- a/server/plugin.go +++ b/server/plugin.go @@ -27,6 +27,9 @@ type Plugin struct { // commandClient is the client used to register and execute slash commands. commandClient command.Command + // logger is the main plugin logger + logger Logger + backgroundJob *cluster.Job // configurationLock synchronizes access to the configuration. @@ -41,6 +44,9 @@ type Plugin struct { func (p *Plugin) OnActivate() error { p.client = pluginapi.NewClient(p.API, p.Driver) + // Initialize the logger using Mattermost Plugin API + p.logger = NewPluginAPILogger(p.API) + p.kvstore = kvstore.NewKVStore(p.client) p.commandClient = command.NewCommandHandler(p.client) diff --git a/server/store/kvstore/constants.go b/server/store/kvstore/constants.go index a100e21..a452c91 100644 --- a/server/store/kvstore/constants.go +++ b/server/store/kvstore/constants.go @@ -7,43 +7,43 @@ package kvstore const ( // CurrentKVStoreVersion is the current version requiring migrations CurrentKVStoreVersion = 2 - // KeyPrefixMatrixUser is the prefix for Matrix user ID -> Mattermost user ID mappings - KeyPrefixMatrixUser = "matrix_user_" - // KeyPrefixMattermostUser is the prefix for Mattermost user ID -> Matrix user ID mappings + // KeyPrefixXMPPUser is the prefix for XMPP user ID -> Mattermost user ID mappings + KeyPrefixXMPPUser = "xmpp_user_" + // KeyPrefixMattermostUser is the prefix for Mattermost user ID -> XMPP user ID mappings KeyPrefixMattermostUser = "mattermost_user_" - // KeyPrefixChannelMapping is the prefix for Mattermost channel ID -> Matrix room mappings + // KeyPrefixChannelMapping is the prefix for Mattermost channel ID -> XMPP room mappings KeyPrefixChannelMapping = "channel_mapping_" - // KeyPrefixRoomMapping is the prefix for Matrix room identifier -> Mattermost channel ID mappings - KeyPrefixRoomMapping = "room_mapping_" + // KeyPrefixRoomMapping is the prefix for XMPP room identifier -> Mattermost channel ID mappings + KeyPrefixRoomMapping = "xmpp_room_mapping_" - // KeyPrefixGhostUser is the prefix for Mattermost user ID -> Matrix ghost user ID cache + // KeyPrefixGhostUser is the prefix for Mattermost user ID -> XMPP ghost user ID cache KeyPrefixGhostUser = "ghost_user_" // KeyPrefixGhostRoom is the prefix for ghost user room membership tracking KeyPrefixGhostRoom = "ghost_room_" - // KeyPrefixMatrixEventPost is the prefix for Matrix event ID -> Mattermost post ID mappings - KeyPrefixMatrixEventPost = "matrix_event_post_" - // KeyPrefixMatrixReaction is the prefix for Matrix reaction event ID -> reaction info mappings - KeyPrefixMatrixReaction = "matrix_reaction_" + // KeyPrefixXMPPEventPost is the prefix for XMPP event ID -> Mattermost post ID mappings + KeyPrefixXMPPEventPost = "xmpp_event_post_" + // KeyPrefixXMPPReaction is the prefix for XMPP reaction event ID -> reaction info mappings + KeyPrefixXMPPReaction = "xmpp_reaction_" // KeyStoreVersion is the key for tracking the current KV store schema version KeyStoreVersion = "kv_store_version" // KeyPrefixLegacyDMMapping was the old prefix for DM mappings (migrated to channel_mapping_) KeyPrefixLegacyDMMapping = "dm_mapping_" - // KeyPrefixLegacyMatrixDMMapping was the old prefix for Matrix DM mappings (migrated to room_mapping_) - KeyPrefixLegacyMatrixDMMapping = "matrix_dm_mapping_" + // KeyPrefixLegacyXMPPDMMapping was the old prefix for XMPP DM mappings (migrated to room_mapping_) + KeyPrefixLegacyXMPPDMMapping = "xmpp_dm_mapping_" ) // Helper functions for building KV store keys -// BuildMatrixUserKey creates a key for Matrix user -> Mattermost user mapping -func BuildMatrixUserKey(matrixUserID string) string { - return KeyPrefixMatrixUser + matrixUserID +// BuildXMPPUserKey creates a key for XMPP user -> Mattermost user mapping +func BuildXMPPUserKey(xmppUserID string) string { + return KeyPrefixXMPPUser + xmppUserID } -// BuildMattermostUserKey creates a key for Mattermost user -> Matrix user mapping +// BuildMattermostUserKey creates a key for Mattermost user -> XMPP user mapping func BuildMattermostUserKey(mattermostUserID string) string { return KeyPrefixMattermostUser + mattermostUserID } @@ -68,12 +68,12 @@ func BuildGhostRoomKey(mattermostUserID, roomID string) string { return KeyPrefixGhostRoom + mattermostUserID + "_" + roomID } -// BuildMatrixEventPostKey creates a key for Matrix event -> post mapping -func BuildMatrixEventPostKey(matrixEventID string) string { - return KeyPrefixMatrixEventPost + matrixEventID +// BuildXMPPEventPostKey creates a key for XMPP event -> post mapping +func BuildXMPPEventPostKey(xmppEventID string) string { + return KeyPrefixXMPPEventPost + xmppEventID } -// BuildMatrixReactionKey creates a key for Matrix reaction storage -func BuildMatrixReactionKey(reactionEventID string) string { - return KeyPrefixMatrixReaction + reactionEventID +// BuildXMPPReactionKey creates a key for XMPP reaction storage +func BuildXMPPReactionKey(reactionEventID string) string { + return KeyPrefixXMPPReaction + reactionEventID } diff --git a/server/testhelpers_test.go b/server/testhelpers_test.go deleted file mode 100644 index f16ecd5..0000000 --- a/server/testhelpers_test.go +++ /dev/null @@ -1,424 +0,0 @@ -package main - -import ( - "os" - "sort" - "strings" - "sync" - "testing" - "time" - - "github.com/mattermost/mattermost-plugin-matrix-bridge/server/matrix" - "github.com/mattermost/mattermost-plugin-matrix-bridge/server/store/kvstore" - matrixtest "github.com/mattermost/mattermost-plugin-matrix-bridge/testcontainers/matrix" - "github.com/mattermost/mattermost/server/public/model" - "github.com/mattermost/mattermost/server/public/plugin" - "github.com/mattermost/mattermost/server/public/plugin/plugintest" - "github.com/pkg/errors" - "github.com/stretchr/testify/mock" -) - -// testLogger implements Logger interface for testing -type testLogger struct { - t *testing.T -} - -func (l *testLogger) LogDebug(message string, keyValuePairs ...any) { - if l.t != nil { - l.t.Logf("[DEBUG] %s %v", message, keyValuePairs) - } -} - -func (l *testLogger) LogInfo(message string, keyValuePairs ...any) { - if l.t != nil { - l.t.Logf("[INFO] %s %v", message, keyValuePairs) - } -} - -func (l *testLogger) LogWarn(message string, keyValuePairs ...any) { - if l.t != nil { - l.t.Logf("[WARN] %s %v", message, keyValuePairs) - } -} - -func (l *testLogger) LogError(message string, keyValuePairs ...any) { - if l.t != nil { - l.t.Logf("[ERROR] %s %v", message, keyValuePairs) - } -} - -// TestSetup contains common test setup data for integration tests -type TestSetup struct { - Plugin *Plugin - ChannelID string - UserID string - RoomID string - GhostUserID string - API *plugintest.API -} - -// setupPluginForTest creates a basic plugin instance with mock API for unit tests -func setupPluginForTest() *Plugin { - api := &plugintest.API{} - - // Allow any logging calls since we're not testing logging behavior - api.On("LogDebug", mock.Anything, mock.Anything).Maybe() - api.On("LogDebug", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe() - api.On("LogDebug", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe() - - plugin := &Plugin{} - plugin.SetAPI(api) - plugin.logger = &testLogger{} - return plugin -} - -// setupPluginForTestWithLogger creates a plugin instance with test logger that logs to testing.T -func setupPluginForTestWithLogger(t *testing.T, api plugin.API) *Plugin { - plugin := &Plugin{} - plugin.API = api - plugin.logger = &testLogger{t: t} - return plugin -} - -// setupPluginForTestWithKVStore creates a plugin instance with test logger, API, and KV store -func setupPluginForTestWithKVStore(t *testing.T, api plugin.API, kvstore kvstore.KVStore) *Plugin { - plugin := &Plugin{} - plugin.API = api - plugin.kvstore = kvstore - plugin.logger = &testLogger{t: t} - return plugin -} - -// createMatrixClientWithTestLogger creates a matrix client with test logger for testing -func createMatrixClientWithTestLogger(t *testing.T, serverURL, asToken, remoteID string) *matrix.Client { - testLogger := matrix.NewTestLogger(t) - return matrix.NewClientWithLogger(serverURL, asToken, remoteID, testLogger) -} - -// TestMatrixClientTestLogger verifies that matrix client uses test logger correctly -func TestMatrixClientTestLogger(t *testing.T) { - // Create a matrix client with test logger - client := createMatrixClientWithTestLogger(t, "https://test.example.com", "test_token", "test_remote") - - // This would trigger logging if the matrix client were to log something - // Since we can't easily test actual HTTP calls without a server, this test mainly - // verifies that the client is created correctly with a test logger - if client == nil { - t.Error("Matrix client should not be nil") - } - - // Log success - this confirms the test logger interface is working - t.Log("Matrix client created successfully with test logger") -} - -// setupTestPlugin creates a test plugin instance with Matrix container for integration tests -func setupTestPlugin(t *testing.T, matrixContainer *matrixtest.MatrixContainer) *TestSetup { - api := &plugintest.API{} - - testChannelID := model.NewId() - testUserID := model.NewId() - testRoomID := matrixContainer.CreateRoom(t, "Test Room") - testGhostUserID := "@_mattermost_" + testUserID + ":" + matrixContainer.ServerDomain - - plugin := &Plugin{remoteID: "test-remote-id"} - plugin.SetAPI(api) - - // Initialize kvstore with in-memory implementation for testing - plugin.kvstore = NewMemoryKVStore() - - // Initialize required plugin components - plugin.pendingFiles = NewPendingFileTracker() - plugin.postTracker = NewPostTracker(DefaultPostTrackerMaxEntries) - - plugin.matrixClient = createMatrixClientWithTestLogger( - t, - matrixContainer.ServerURL, - matrixContainer.ASToken, - plugin.remoteID, - ) - // Set explicit server domain for testing - plugin.matrixClient.SetServerDomain(matrixContainer.ServerDomain) - - config := &configuration{ - MatrixServerURL: matrixContainer.ServerURL, - MatrixASToken: matrixContainer.ASToken, - MatrixHSToken: matrixContainer.HSToken, - } - plugin.configuration = config - - // Set up basic mocks - setupBasicMocks(api, testUserID) - - // Set up test data in KV store - setupTestKVData(plugin.kvstore, testChannelID, testRoomID) - - // Initialize the logger with test implementation - plugin.logger = &testLogger{t: t} - - // Initialize bridges for testing - plugin.initBridges() - - return &TestSetup{ - Plugin: plugin, - ChannelID: testChannelID, - UserID: testUserID, - RoomID: testRoomID, - GhostUserID: testGhostUserID, - API: api, - } -} - -// setupBasicMocks sets up common API mocks for integration tests -func setupBasicMocks(api *plugintest.API, testUserID string) { - // Basic user mock - testUser := &model.User{ - Id: testUserID, - Username: "testuser", - Email: "test@example.com", - Nickname: "Test User", - } - api.On("GetUser", testUserID).Return(testUser, nil) - api.On("GetUser", mock.AnythingOfType("string")).Return(&model.User{Id: "default", Username: "default"}, nil) - - // Mock profile image for ghost user creation - api.On("GetProfileImage", testUserID).Return([]byte("fake-image-data"), nil) - - // Post update mock - return the updated post with current timestamp - api.On("UpdatePost", mock.AnythingOfType("*model.Post")).Return(func(post *model.Post) *model.Post { - // Simulate what Mattermost does - update the UpdateAt timestamp - updatedPost := post.Clone() // Copy the post - updatedPost.UpdateAt = time.Now().UnixMilli() - return updatedPost - }, nil) - - // Logging mocks - handle variable argument types - api.On("LogDebug", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return() - api.On("LogInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return() - api.On("LogWarn", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return() -} - -// setupTestKVData sets up initial test data in the KV store -func setupTestKVData(kvstore kvstore.KVStore, testChannelID, testRoomID string) { - // Set up channel mapping - _ = kvstore.Set("channel_mapping_"+testChannelID, []byte(testRoomID)) - - // Ghost users and ghost rooms are intentionally not set up here - // to trigger creation during tests, which validates the creation logic -} - -// setupMentionMocks sets up mocks for testing user mentions -func setupMentionMocks(api *plugintest.API, userID, username string) { - user := &model.User{Id: userID, Username: username, Email: username + "@example.com"} - api.On("GetUserByUsername", username).Return(user, nil) - // Mock profile image for ghost user creation - api.On("GetProfileImage", userID).Return([]byte("fake-image-data"), nil) -} - -// clearMockExpectations clears all previous mock expectations for reuse in subtests -func clearMockExpectations(api *plugintest.API) { - api.ExpectedCalls = nil -} - -// Helper function to compare file attachment arrays (moved from sync_to_matrix_test.go) -func compareFileAttachmentArrays(currentFiles, newFiles []matrix.FileAttachment) bool { - if len(currentFiles) != len(newFiles) { - return false - } - - for i, newFile := range newFiles { - if i >= len(currentFiles) { - return false - } - - currentFile := currentFiles[i] - if currentFile.Filename != newFile.Filename || - currentFile.MxcURI != newFile.MxcURI || - currentFile.MimeType != newFile.MimeType || - currentFile.Size != newFile.Size { - return false - } - } - - return true -} - -// MemoryKVStore provides an in-memory implementation of the KVStore interface for testing. -type MemoryKVStore struct { - data map[string][]byte - mu sync.RWMutex -} - -// NewMemoryKVStore creates a new in-memory KV store for testing. -func NewMemoryKVStore() kvstore.KVStore { - return &MemoryKVStore{ - data: make(map[string][]byte), - } -} - -// GetTemplateData retrieves template data for a specific user from the KV store. -func (m *MemoryKVStore) GetTemplateData(userID string) (string, error) { - m.mu.RLock() - defer m.mu.RUnlock() - - key := "template_key-" + userID - if data, exists := m.data[key]; exists { - return string(data), nil - } - return "", errors.New("key not found") -} - -// Get retrieves a value from the KV store by key. -func (m *MemoryKVStore) Get(key string) ([]byte, error) { - m.mu.RLock() - defer m.mu.RUnlock() - - if data, exists := m.data[key]; exists { - // Return a copy to prevent external modification - result := make([]byte, len(data)) - copy(result, data) - return result, nil - } - return nil, errors.New("key not found") -} - -// Set stores a key-value pair in the KV store. -func (m *MemoryKVStore) Set(key string, value []byte) error { - m.mu.Lock() - defer m.mu.Unlock() - - // Store a copy to prevent external modification - data := make([]byte, len(value)) - copy(data, value) - m.data[key] = data - return nil -} - -// Delete removes a key-value pair from the KV store. -func (m *MemoryKVStore) Delete(key string) error { - m.mu.Lock() - defer m.mu.Unlock() - - delete(m.data, key) - return nil -} - -// ListKeys retrieves a paginated list of keys from the KV store. -func (m *MemoryKVStore) ListKeys(page, perPage int) ([]string, error) { - m.mu.RLock() - defer m.mu.RUnlock() - - // Collect all keys - keys := make([]string, 0, len(m.data)) - for key := range m.data { - keys = append(keys, key) - } - - // Sort keys for consistent ordering - sort.Strings(keys) - - // Apply pagination - start := page * perPage - if start >= len(keys) { - return []string{}, nil - } - - end := start + perPage - if end > len(keys) { - end = len(keys) - } - - return keys[start:end], nil -} - -// ListKeysWithPrefix retrieves a paginated list of keys with a specific prefix from the KV store. -func (m *MemoryKVStore) ListKeysWithPrefix(page, perPage int, prefix string) ([]string, error) { - m.mu.RLock() - defer m.mu.RUnlock() - - // Collect keys with the specified prefix - keys := make([]string, 0, len(m.data)) - for key := range m.data { - if strings.HasPrefix(key, prefix) { - keys = append(keys, key) - } - } - - // Sort keys for consistent ordering - sort.Strings(keys) - - // Apply pagination - start := page * perPage - if start >= len(keys) { - return []string{}, nil - } - - end := start + perPage - if end > len(keys) { - end = len(keys) - } - - return keys[start:end], nil -} - -// Clear removes all data from the store (useful for test cleanup). -func (m *MemoryKVStore) Clear() { - m.mu.Lock() - defer m.mu.Unlock() - - m.data = make(map[string][]byte) -} - -// Size returns the number of key-value pairs in the store. -func (m *MemoryKVStore) Size() int { - m.mu.RLock() - defer m.mu.RUnlock() - - return len(m.data) -} - -// TestMemoryKVStore tests the in-memory KV store implementation -func TestMemoryKVStore(t *testing.T) { - store := NewMemoryKVStore() - - // Test Set and Get - err := store.Set("test-key", []byte("test-value")) - if err != nil { - t.Errorf("Expected no error, got %v", err) - } - - value, err := store.Get("test-key") - if err != nil { - t.Errorf("Expected no error, got %v", err) - } - if string(value) != "test-value" { - t.Errorf("Expected 'test-value', got '%s'", string(value)) - } - - // Test Get non-existent key - _, err = store.Get("non-existent") - if err == nil { - t.Error("Expected error for non-existent key") - } - - // Test Delete - err = store.Delete("test-key") - if err != nil { - t.Errorf("Expected no error, got %v", err) - } - - _, err = store.Get("test-key") - if err == nil { - t.Error("Expected error for deleted key") - } -} - -// TestMain provides global test setup and cleanup -func TestMain(m *testing.M) { - // Run tests - code := m.Run() - - // Ensure all Matrix containers are cleaned up - matrixtest.CleanupAllContainers() - - os.Exit(code) -}