mattermost/server/public/model/shared_channel_test.go
Doug Lauder 81d4fe3793
MM-68339: Add XML struct tags and multi-remote registration for shared channels plugin API (#36126)
* Add XML struct tags and multi-remote registration for shared channels plugin API

  Phase 1: Add xml struct tags to model types used in SyncMsg (Post, User,
  Reaction, Status, PostAcknowledgement, FileInfo, SyncResponse,
  MembershipChangeMsg). Add custom MarshalXML/UnmarshalXML for SyncMsg
  (Users map, MentionTransforms map), StringMap, and StringInterface.
  Exclude Post.Metadata, PrevStatus, and server-internal FileInfo fields
  from XML. JSON serialization is unaffected.

  Phase 2: Lift the one-remote-per-plugin constraint so plugins can
  register multiple remotes with different SiteURLs. Add SiteURL field to
  RegisterPluginOpts (defaults to "plugin_<PluginID>" for backward
  compatibility). Add GetAllByPluginID and GetBySiteURL store methods.
  Rewrite registration to dedup by SiteURL instead of PluginID. Add
  UnregisterPluginRemoteForSharedChannels for single-remote removal with
  plugin ownership validation. Validate SiteURL is non-empty in
  RemoteCluster.IsValid. Simplify IsPlugin() to check PluginID only.
2026-04-28 08:53:06 -04:00

519 lines
14 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"encoding/json"
"encoding/xml"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSharedChannelIsValid(t *testing.T) {
id := NewId()
now := GetMillis()
data := []struct {
name string
sc *SharedChannel
valid bool
}{
{name: "Zero value", sc: &SharedChannel{}, valid: false},
{name: "Missing team_id", sc: &SharedChannel{ChannelId: id}, valid: false},
{name: "Missing create_at", sc: &SharedChannel{ChannelId: id, TeamId: id}, valid: false},
{name: "Missing update_at", sc: &SharedChannel{ChannelId: id, TeamId: id, CreateAt: now}, valid: false},
{name: "Missing share_name", sc: &SharedChannel{ChannelId: id, TeamId: id, CreateAt: now, UpdateAt: now}, valid: false},
{name: "Invalid share_name", sc: &SharedChannel{ChannelId: id, TeamId: id, CreateAt: now, UpdateAt: now,
ShareName: "@test@"}, valid: false},
{name: "Too long share_name", sc: &SharedChannel{ChannelId: id, TeamId: id, CreateAt: now, UpdateAt: now,
ShareName: strings.Repeat("01234567890", 100)}, valid: false},
{name: "Missing creator_id", sc: &SharedChannel{ChannelId: id, TeamId: id, CreateAt: now, UpdateAt: now,
ShareName: "test"}, valid: false},
{name: "Missing remote_id", sc: &SharedChannel{ChannelId: id, TeamId: id, CreateAt: now, UpdateAt: now,
ShareName: "test", CreatorId: id}, valid: false},
{name: "Valid shared channel", sc: &SharedChannel{ChannelId: id, TeamId: id, CreateAt: now, UpdateAt: now,
ShareName: "test", CreatorId: id, RemoteId: id}, valid: true},
}
for _, item := range data {
appErr := item.sc.IsValid()
if item.valid {
assert.Nil(t, appErr, item.name)
} else {
assert.NotNil(t, appErr, item.name)
}
}
}
func TestSharedChannelPreSave(t *testing.T) {
now := GetMillis()
o := SharedChannel{ChannelId: NewId(), ShareName: "test"}
o.PreSave()
require.GreaterOrEqual(t, o.CreateAt, now)
require.GreaterOrEqual(t, o.UpdateAt, now)
}
func TestSharedChannelPreUpdate(t *testing.T) {
now := GetMillis()
o := SharedChannel{ChannelId: NewId(), ShareName: "test"}
o.PreUpdate()
require.GreaterOrEqual(t, o.UpdateAt, now)
}
func TestSyncMsgXMLRoundTrip(t *testing.T) {
remoteID := NewId()
msg := &SyncMsg{
Id: NewId(),
ChannelId: NewId(),
Users: map[string]*User{
"user1": {
Id: NewId(),
Username: "testuser1",
Email: "test1@example.com",
Props: StringMap{"key1": "val1"},
Timezone: StringMap{"useAutomaticTimezone": "true"},
},
"user2": {
Id: NewId(),
Username: "testuser2",
Email: "test2@example.com",
},
},
Posts: []*Post{
{
Id: NewId(),
ChannelId: NewId(),
UserId: NewId(),
Message: "hello world",
Props: StringInterface{"from_webhook": "true", "count": float64(42)},
RemoteId: &remoteID,
},
},
Reactions: []*Reaction{
{
UserId: NewId(),
PostId: NewId(),
EmojiName: "thumbsup",
CreateAt: 1000,
},
},
Statuses: []*Status{
{
UserId: NewId(),
Status: StatusOnline,
},
},
MembershipChanges: []*MembershipChangeMsg{
{
ChannelId: NewId(),
UserId: NewId(),
IsAdd: true,
RemoteId: NewId(),
ChangeTime: 2000,
},
},
Acknowledgements: []*PostAcknowledgement{
{
UserId: NewId(),
PostId: NewId(),
AcknowledgedAt: 3000,
ChannelId: NewId(),
},
},
MentionTransforms: map[string]string{
"@olduser": "@newuser",
"@admin": "@remote_admin",
},
}
data, err := xml.MarshalIndent(msg, "", " ")
require.NoError(t, err)
var decoded SyncMsg
err = xml.Unmarshal(data, &decoded)
require.NoError(t, err)
assert.Equal(t, msg.Id, decoded.Id)
assert.Equal(t, msg.ChannelId, decoded.ChannelId)
assert.Len(t, decoded.Users, 2)
assert.Equal(t, msg.Users["user1"].Username, decoded.Users["user1"].Username)
assert.Equal(t, msg.Users["user2"].Username, decoded.Users["user2"].Username)
assert.Len(t, decoded.Posts, 1)
assert.Equal(t, msg.Posts[0].Message, decoded.Posts[0].Message)
assert.Len(t, decoded.Reactions, 1)
assert.Equal(t, msg.Reactions[0].EmojiName, decoded.Reactions[0].EmojiName)
assert.Len(t, decoded.Statuses, 1)
assert.Equal(t, msg.Statuses[0].Status, decoded.Statuses[0].Status)
assert.Len(t, decoded.MembershipChanges, 1)
assert.Equal(t, msg.MembershipChanges[0].IsAdd, decoded.MembershipChanges[0].IsAdd)
assert.Len(t, decoded.Acknowledgements, 1)
assert.Equal(t, msg.Acknowledgements[0].AcknowledgedAt, decoded.Acknowledgements[0].AcknowledgedAt)
assert.Equal(t, msg.MentionTransforms, decoded.MentionTransforms)
}
func TestSyncMsgXMLUsersMap(t *testing.T) {
msg := &SyncMsg{
Id: NewId(),
Users: map[string]*User{
"alpha": {Id: "id-alpha", Username: "alpha"},
"beta": {Id: "id-beta", Username: "beta"},
"gamma": {Id: "id-gamma", Username: "gamma"},
},
}
data, err := xml.Marshal(msg)
require.NoError(t, err)
var decoded SyncMsg
err = xml.Unmarshal(data, &decoded)
require.NoError(t, err)
require.Len(t, decoded.Users, 3)
assert.Equal(t, "alpha", decoded.Users["alpha"].Username)
assert.Equal(t, "beta", decoded.Users["beta"].Username)
assert.Equal(t, "gamma", decoded.Users["gamma"].Username)
}
func TestSyncMsgXMLMentionTransforms(t *testing.T) {
msg := &SyncMsg{
Id: NewId(),
MentionTransforms: map[string]string{
"@user1": "@remote_user1",
"@user2": "@remote_user2",
},
}
data, err := xml.Marshal(msg)
require.NoError(t, err)
var decoded SyncMsg
err = xml.Unmarshal(data, &decoded)
require.NoError(t, err)
assert.Equal(t, msg.MentionTransforms, decoded.MentionTransforms)
}
func TestSyncMsgXMLEmptyMaps(t *testing.T) {
msg := &SyncMsg{
Id: NewId(),
ChannelId: NewId(),
}
data, err := xml.Marshal(msg)
require.NoError(t, err)
var decoded SyncMsg
err = xml.Unmarshal(data, &decoded)
require.NoError(t, err)
assert.Equal(t, msg.Id, decoded.Id)
assert.Equal(t, msg.ChannelId, decoded.ChannelId)
assert.Nil(t, decoded.Users)
assert.Nil(t, decoded.MentionTransforms)
}
func TestPostXMLRoundTrip(t *testing.T) {
remoteID := NewId()
post := &Post{
Id: NewId(),
CreateAt: 1000,
UpdateAt: 2000,
UserId: NewId(),
ChannelId: NewId(),
Message: "test message with <xml> special & chars",
Props: StringInterface{
"from_webhook": "true",
"count": float64(42),
"nested": map[string]any{"a": float64(1), "b": "two"},
},
FileIds: StringArray{"file1", "file2"},
RemoteId: &remoteID,
Metadata: &PostMetadata{}, // should be excluded from XML
}
data, err := xml.MarshalIndent(post, "", " ")
require.NoError(t, err)
var decoded Post
err = xml.Unmarshal(data, &decoded)
require.NoError(t, err)
assert.Equal(t, post.Id, decoded.Id)
assert.Equal(t, post.Message, decoded.Message)
assert.Equal(t, post.CreateAt, decoded.CreateAt)
assert.Equal(t, "true", decoded.Props["from_webhook"])
assert.Equal(t, float64(42), decoded.Props["count"])
assert.Equal(t, post.FileIds, decoded.FileIds)
assert.Equal(t, *post.RemoteId, *decoded.RemoteId)
assert.Nil(t, decoded.Metadata, "Metadata should be excluded from XML")
}
func TestUserXMLRoundTrip(t *testing.T) {
remoteID := NewId()
user := &User{
Id: NewId(),
Username: "testuser",
Email: "test@example.com",
FirstName: "Test",
LastName: "User",
Props: StringMap{"customkey": "customval"},
NotifyProps: StringMap{"push": "all", "desktop": "mention"},
Timezone: StringMap{"useAutomaticTimezone": "true", "manualTimezone": "America/New_York"},
RemoteId: &remoteID,
}
data, err := xml.MarshalIndent(user, "", " ")
require.NoError(t, err)
var decoded User
err = xml.Unmarshal(data, &decoded)
require.NoError(t, err)
assert.Equal(t, user.Id, decoded.Id)
assert.Equal(t, user.Username, decoded.Username)
assert.Equal(t, user.Email, decoded.Email)
assert.Equal(t, user.Props, decoded.Props)
assert.Equal(t, user.NotifyProps, decoded.NotifyProps)
assert.Equal(t, user.Timezone, decoded.Timezone)
assert.Equal(t, *user.RemoteId, *decoded.RemoteId)
}
func TestReactionXMLRoundTrip(t *testing.T) {
remoteID := NewId()
reaction := &Reaction{
UserId: NewId(),
PostId: NewId(),
EmojiName: "thumbsup",
CreateAt: 1000,
UpdateAt: 2000,
RemoteId: &remoteID,
ChannelId: NewId(),
}
data, err := xml.Marshal(reaction)
require.NoError(t, err)
var decoded Reaction
err = xml.Unmarshal(data, &decoded)
require.NoError(t, err)
assert.Equal(t, reaction.UserId, decoded.UserId)
assert.Equal(t, reaction.PostId, decoded.PostId)
assert.Equal(t, reaction.EmojiName, decoded.EmojiName)
assert.Equal(t, reaction.CreateAt, decoded.CreateAt)
assert.Equal(t, *reaction.RemoteId, *decoded.RemoteId)
}
func TestStatusXMLRoundTrip(t *testing.T) {
status := &Status{
UserId: NewId(),
Status: StatusOnline,
Manual: true,
LastActivityAt: 5000,
DNDEndTime: 6000,
PrevStatus: StatusAway, // should be excluded
}
data, err := xml.Marshal(status)
require.NoError(t, err)
var decoded Status
err = xml.Unmarshal(data, &decoded)
require.NoError(t, err)
assert.Equal(t, status.UserId, decoded.UserId)
assert.Equal(t, status.Status, decoded.Status)
assert.Equal(t, status.Manual, decoded.Manual)
assert.Equal(t, status.DNDEndTime, decoded.DNDEndTime)
assert.Empty(t, decoded.PrevStatus, "PrevStatus should be excluded from XML")
}
func TestPostAcknowledgementXMLRoundTrip(t *testing.T) {
remoteID := NewId()
ack := &PostAcknowledgement{
UserId: NewId(),
PostId: NewId(),
AcknowledgedAt: 3000,
ChannelId: NewId(),
RemoteId: &remoteID,
}
data, err := xml.Marshal(ack)
require.NoError(t, err)
var decoded PostAcknowledgement
err = xml.Unmarshal(data, &decoded)
require.NoError(t, err)
assert.Equal(t, ack.UserId, decoded.UserId)
assert.Equal(t, ack.PostId, decoded.PostId)
assert.Equal(t, ack.AcknowledgedAt, decoded.AcknowledgedAt)
assert.Equal(t, ack.ChannelId, decoded.ChannelId)
assert.Equal(t, *ack.RemoteId, *decoded.RemoteId)
}
func TestFileInfoXMLRoundTrip(t *testing.T) {
remoteID := NewId()
fi := &FileInfo{
Id: NewId(),
CreatorId: NewId(),
PostId: NewId(),
ChannelId: NewId(),
CreateAt: 1000,
UpdateAt: 2000,
Name: "test.txt",
Extension: "txt",
Size: 1024,
MimeType: "text/plain",
RemoteId: &remoteID,
Path: "/data/test.txt",
ThumbnailPath: "/data/test_thumb.txt",
PreviewPath: "/data/test_preview.txt",
Content: "file content for search",
}
data, err := xml.Marshal(fi)
require.NoError(t, err)
var decoded FileInfo
err = xml.Unmarshal(data, &decoded)
require.NoError(t, err)
assert.Equal(t, fi.Id, decoded.Id)
assert.Equal(t, fi.CreatorId, decoded.CreatorId)
assert.Equal(t, fi.Name, decoded.Name)
assert.Equal(t, fi.Size, decoded.Size)
assert.Equal(t, *fi.RemoteId, *decoded.RemoteId)
assert.Empty(t, decoded.Path, "Path should be excluded from XML")
assert.Empty(t, decoded.ThumbnailPath, "ThumbnailPath should be excluded from XML")
assert.Empty(t, decoded.PreviewPath, "PreviewPath should be excluded from XML")
assert.Empty(t, decoded.Content, "Content should be excluded from XML")
}
func TestStringMapXMLRoundTrip(t *testing.T) {
m := StringMap{
"key1": "value1",
"key2": "value2",
"empty": "",
}
data, err := xml.Marshal(m)
require.NoError(t, err)
var decoded StringMap
err = xml.Unmarshal(data, &decoded)
require.NoError(t, err)
assert.Equal(t, m, decoded)
}
func TestStringInterfaceXMLRoundTrip(t *testing.T) {
m := StringInterface{
"string_val": "hello",
"number_val": float64(42),
"bool_val": true,
"null_val": nil,
"nested": map[string]any{"a": float64(1), "b": "two"},
}
data, err := xml.Marshal(m)
require.NoError(t, err)
var decoded StringInterface
err = xml.Unmarshal(data, &decoded)
require.NoError(t, err)
assert.Equal(t, "hello", decoded["string_val"])
assert.Equal(t, float64(42), decoded["number_val"])
assert.Equal(t, true, decoded["bool_val"])
assert.Nil(t, decoded["null_val"])
nested, ok := decoded["nested"].(map[string]any)
require.True(t, ok)
assert.Equal(t, float64(1), nested["a"])
assert.Equal(t, "two", nested["b"])
}
func TestXMLDoesNotAffectJSON(t *testing.T) {
remoteID := NewId()
t.Run("SyncMsg", func(t *testing.T) {
msg := &SyncMsg{
Id: NewId(),
ChannelId: NewId(),
Users: map[string]*User{
"u1": {Id: NewId(), Username: "u1"},
},
Posts: []*Post{{Id: NewId(), Message: "test"}},
MentionTransforms: map[string]string{"@a": "@b"},
}
data, err := json.Marshal(msg)
require.NoError(t, err)
var decoded SyncMsg
err = json.Unmarshal(data, &decoded)
require.NoError(t, err)
assert.Equal(t, msg.Id, decoded.Id)
assert.Equal(t, msg.Users["u1"].Username, decoded.Users["u1"].Username)
assert.Equal(t, msg.MentionTransforms, decoded.MentionTransforms)
})
t.Run("Post", func(t *testing.T) {
post := &Post{
Id: NewId(),
Message: "test",
Props: StringInterface{"key": "val"},
RemoteId: &remoteID,
Metadata: &PostMetadata{},
}
data, err := json.Marshal(post)
require.NoError(t, err)
var decoded Post
err = json.Unmarshal(data, &decoded)
require.NoError(t, err)
assert.Equal(t, post.Id, decoded.Id)
assert.Equal(t, post.Props["key"], decoded.Props["key"])
assert.NotNil(t, decoded.Metadata, "Metadata should be present in JSON")
})
t.Run("User", func(t *testing.T) {
user := &User{
Id: NewId(),
Username: "test",
Props: StringMap{"k": "v"},
Timezone: StringMap{"tz": "utc"},
}
data, err := json.Marshal(user)
require.NoError(t, err)
var decoded User
err = json.Unmarshal(data, &decoded)
require.NoError(t, err)
assert.Equal(t, user.Id, decoded.Id)
assert.Equal(t, user.Props, decoded.Props)
assert.Equal(t, user.Timezone, decoded.Timezone)
})
t.Run("SyncResponse", func(t *testing.T) {
sr := &SyncResponse{
UsersLastUpdateAt: 1000,
UserErrors: []string{"err1"},
PostsLastUpdateAt: 2000,
}
data, err := json.Marshal(sr)
require.NoError(t, err)
var decoded SyncResponse
err = json.Unmarshal(data, &decoded)
require.NoError(t, err)
assert.Equal(t, sr.UsersLastUpdateAt, decoded.UsersLastUpdateAt)
assert.Equal(t, sr.UserErrors, decoded.UserErrors)
})
}