snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 00:29:55 +05:00
commit 240db4e2a7
15309 changed files with 2173406 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
with-expecter: false
packages:
github.com/mattermost/mattermost/server/v8/platform/services/sharedchannel:
config:
dir: "platform/services/sharedchannel"
inpackage: true
filename: "mock_{{.InterfaceName}}_test.go"
outpkg: "sharedchannel"
note: "Regenerate this file using `make sharedchannel-mocks`."
interfaces:
ServerIface:
AppIface:

View File

@@ -0,0 +1,519 @@
## Shared Channel Service
Package `sharedchannel` implements Mattermost's shared channels functionality, for sharing channel content across Mattermost instances/clusters. Here are the key responsibilities:
### Channel Sharing:
- Allows channels to be shared between different Mattermost instances/clusters
- Handles inviting remote clusters to shared channels
- Manages permissions and read-only status for shared channels
### Content Synchronization:
- Syncs posts, reactions, user profiles, and file attachments between instances
- Handles permalink processing between instances
- Manages user profile images sync
- Maintains sync state and cursors to track what has been synchronized
### Remote Communication:
- Processes incoming sync messages from remote clusters
- Sends updates to remote clusters when local changes occur
- Handles connection state changes with remote clusters
- Manages retry logic for failed sync attempts
### Security:
- Validates permissions for shared channel operations
- Ensures users can only sync content they have access to
- Verifies remote cluster authenticity
- Sanitizes user data during sync
The service acts as a bridge between Mattermost instances, allowing users from different instances to collaborate in shared channels while keeping content synchronized across all participating instances.
This is implemented through a Service struct that handles all the shared channel operations and maintains the synchronization state. It works in conjunction with the RemoteCluster service to handle the actual communication between instances.
---
## API Calls and Flow Between Mattermost Instances
### Overview
Shared channels enable two Mattermost instances to synchronize specific channels. The architecture uses:
- **Remote Cluster Service** - Handles inter-cluster communication
- **Shared Channel Service** - Manages channel synchronization
- **Push-based sync** - Each server pushes changes to remotes
- **Cursor-based tracking** - Timestamps track sync progress
```mermaid
graph TB
subgraph "Server A"
A1[User/App Layer]
A2[Shared Channel Service]
A3[Remote Cluster Service]
A4[API Endpoints]
A5[Database]
A1 --> A2
A2 --> A3
A2 --> A5
A3 --> A4
end
subgraph "Server B"
B1[User/App Layer]
B2[Shared Channel Service]
B3[Remote Cluster Service]
B4[API Endpoints]
B5[Database]
B1 --> B2
B2 --> B3
B2 --> B5
B3 --> B4
end
A4 -->|"HTTP/HTTPS<br/>Token Auth"| B4
B4 -->|"HTTP/HTTPS<br/>Token Auth"| A4
A3 -.->|"Heartbeat<br/>(60s)"| B4
B3 -.->|"Heartbeat<br/>(60s)"| A4
```
### Key Data Structures
```mermaid
erDiagram
RemoteCluster ||--o{ SharedChannel : "connects to"
RemoteCluster ||--o{ SharedChannelRemote : "has"
SharedChannel ||--o{ SharedChannelRemote : "shared with"
Channel ||--|| SharedChannel : "is"
RemoteCluster ||--o{ User : "has synthetic"
RemoteCluster {
string RemoteId PK
string Name
string SiteURL
string Token
string RemoteToken
int64 LastPingAt
int64 LastGlobalUserSyncAt
}
SharedChannel {
string ChannelId PK
string TeamId
bool Home
bool ReadOnly
string RemoteId FK
string ShareName
}
SharedChannelRemote {
string Id PK
string ChannelId FK
string RemoteId FK
bool IsInviteAccepted
bool IsInviteConfirmed
int64 LastPostCreateAt
int64 LastPostUpdateAt
}
Channel {
string Id PK
string TeamId
string Name
string Type
}
User {
string Id PK
string Username
string Email
string RemoteId FK
}
```
**RemoteCluster** (`server/public/model/remote_cluster.go:56`)
- Connection between two Mattermost instances
- Contains authentication tokens (bidirectional)
- Tracks heartbeat status (`LastPingAt`)
**SharedChannel** (`server/public/model/shared_channel.go:32`)
- Represents a shared channel
- `Home=true`: Hosted locally, `Home=false`: Remote channel
- Contains channel metadata snapshot
**SharedChannelRemote** (`server/public/model/shared_channel.go:102`)
- Junction table linking channels to remote clusters
- Tracks sync cursors (`LastPostCreateAt`, `LastPostUpdateAt`)
### Flow Outline
#### 1. Remote Cluster Connection Setup
```mermaid
sequenceDiagram
participant UA as User A
participant SA as Server A
participant UB as User B
participant SB as Server B
Note over UA,SB: Phase 1: Generate Invitation
UA->>SA: POST /api/v4/remotecluster<br/>{name, display_name, password}
SA->>SA: Generate RemoteId, Token
SA->>SA: Encrypt invitation (PBKDF2 + AES-GCM)
SA->>UA: Return encrypted invite code
Note over UA,SB: Phase 2: Accept Invitation
UA->>UB: Share invite code + password<br/>(out of band)
UB->>SB: POST /api/v4/remotecluster/accept_invite<br/>{invite, password}
SB->>SB: Decrypt invitation
SB->>SB: Create RemoteCluster record
SB->>SA: POST /api/v4/remotecluster/confirm_invite<br/>X-MM-RemoteCluster-Token: [token]<br/>{remote_id, site_url, token}
SA->>SA: Update RemoteCluster with SiteURL
SA->>SB: 200 OK
SB->>UB: Connection established
Note over UA,SB: Phase 3: Continuous Heartbeat
loop Every 60 seconds
SA->>SB: POST /api/v4/remotecluster/ping<br/>{sent_at}
SB->>SA: {sent_at, recv_at}
SB->>SA: POST /api/v4/remotecluster/ping<br/>{sent_at}
SA->>SB: {sent_at, recv_at}
end
```
**Step 1: Create Invitation (Server A)**
- API: `POST /api/v4/remotecluster`
- Generates encrypted invitation with PBKDF2 encryption
- Returns base64-encoded invite code
- Creates pending RemoteCluster record
**Step 2: Accept Invitation (Server B)**
- API: `POST /api/v4/remotecluster/accept_invite`
- Decrypts invitation using password
- Creates local RemoteCluster record
- Sends confirmation to Server A
**Step 3: Confirm Connection (Server A)**
- API: `POST /api/v4/remotecluster/confirm_invite`
- Receives confirmation from Server B
- Updates RemoteCluster with actual SiteURL
- Connection established
**Step 4: Continuous Heartbeat**
- API: `POST /api/v4/remotecluster/ping`
- Every 60 seconds (default)
- Updates `LastPingAt` timestamp
- Remote considered online if pinged within 5 minutes
#### 2. Channel Sharing
```mermaid
sequenceDiagram
participant UA as User A
participant SA as Server A
participant SB as Server B
participant UB as User B
Note over UA,SB: Invite Remote to Channel
UA->>SA: Invite Remote B to Channel
SA->>SA: Create SharedChannel (Home=true)
SA->>SA: Create SharedChannelRemote
SA->>SB: POST /api/v4/remotecluster/msg<br/>Topic: sharedchannel_invite<br/>{channel_id, name, type, ...}
SB->>SB: Validate invitation
SB->>SB: Create local channel
SB->>SB: Create SharedChannel (Home=false)
SB->>SB: Create SharedChannelRemote<br/>(IsInviteAccepted=true)
SB->>SA: 200 OK
SA->>SA: Update SharedChannelRemote<br/>(IsInviteConfirmed=true)
SA->>UA: Ephemeral: Remote added to channel
Note over UA,SB: Initial Sync
SA->>SA: Queue sync task for channel
SA->>SA: Collect users, posts, reactions
SA->>SB: POST /api/v4/remotecluster/msg<br/>Topic: sharedchannel_sync<br/>{users, posts, reactions, ...}
SB->>SB: Process sync message
SB->>SB: Create synthetic users
SB->>SB: Create posts
SB->>SB: Add reactions
SB->>SA: 200 OK {timestamps}
SA->>SA: Update sync cursors
```
**Step 1: Share Channel (Server A)**
- Internal: `InviteRemoteToChannel()`
- Creates SharedChannel record (`Home=true`)
- Sends invitation message via topic `sharedchannel_invite`
- Contains channel metadata (name, type, permissions)
**Step 2: Receive Invitation (Server B)**
- API: `POST /api/v4/remotecluster/msg` (topic: `sharedchannel_invite`)
- Creates local channel (regular, DM, or GM)
- Creates SharedChannel record (`Home=false`)
- Creates SharedChannelRemote record
- Returns 200 OK
**Step 3: Initial Sync Triggered**
- Server A receives confirmation
- Posts ephemeral notification to channel
- Triggers initial content synchronization
#### 3. Content Synchronization
```mermaid
sequenceDiagram
participant UA as User A
participant SA as Server A
participant SB as Server B
participant UB as User B
Note over UA,SB: User A posts message
UA->>SA: Create Post
SA->>SA: Save post to database
SA->>UA: WebSocket: posted event
SA->>SA: Queue sync task (2s delay)
Note over UA,SB: Sync Task Processing
SA->>SA: Collect data:<br/>- Users (updated profiles)<br/>- Posts (new/edited)<br/>- Reactions<br/>- Attachments
SA->>SA: Filter & batch (100 posts max)
alt Has file attachments
SA->>SB: POST /api/v4/remotecluster/msg<br/>Topic: sharedchannel_upload<br/>{upload_session}
SB->>SA: 200 OK {session_id}
SA->>SB: POST /api/v4/remotecluster/upload/{id}<br/>multipart file data
SB->>SB: Save file to filestore
end
SA->>SB: POST /api/v4/remotecluster/msg<br/>Topic: sharedchannel_sync<br/>Headers: X-MM-RemoteCluster-Id, Token<br/>{SyncMsg}
Note over SB: Process Sync Message
SB->>SB: Validate auth token
SB->>SB: Process users (create synthetic)
SB->>SB: Process posts (transform mentions)
SB->>SB: Process reactions
SB->>SB: Process acknowledgements
SB->>SB: Update user statuses
SB->>UB: WebSocket: posted event
SB->>SA: 200 OK {timestamps, syncd_users}
SA->>SA: Update sync cursors:<br/>LastPostCreateAt, LastPostUpdateAt
Note over UA,SB: Bidirectional Sync
UB->>SB: Create Post (reply)
SB->>SB: Save post
SB->>UB: WebSocket: posted event
SB->>SB: Queue sync task
SB->>SA: POST /api/v4/remotecluster/msg<br/>Topic: sharedchannel_sync
SA->>SA: Process sync message
SA->>UA: WebSocket: posted event
SA->>SB: 200 OK {timestamps}
```
**Sync Architecture:**
- **Event-driven**: Channel changes trigger sync tasks
- **Batched**: Groups changes for efficiency (100 posts/batch)
- **Ordered**: Users → Attachments → Posts → Reactions → Acknowledgements
**Sync Task Creation:**
Triggered by:
- Post created/edited/deleted
- Reaction added/removed
- User profile updated
- Channel metadata changed
- User status changed
- Membership changed
```mermaid
graph LR
A[Channel Event] -->|NotifyChannelChanged| B[Task Queue]
B -->|2s min delay| C{Remote Online?}
C -->|Yes| D[Collect Data]
C -->|No| E[Skip, retry later]
D --> F[Batch Data<br/>100 posts max]
F --> G[Send to Remote]
G -->|Success| H[Update Cursors]
G -->|Failure| I{Retry Count < 3?}
I -->|Yes| B
I -->|No| J[Log Error & Drop]
H --> K{More Data?}
K -->|Yes| B
K -->|No| L[Done]
```
**Data Collection:**
- Users: 25 per batch (profiles updated since last sync)
- Posts: 100 per batch (new posts first, then edited)
- Reactions: All for synced posts
- Attachments: All for synced posts
**Send Sync Message (Server A → Server B)**
- API: `POST /api/v4/remotecluster/msg` (topic: `sharedchannel_sync`)
- Headers:
- `X-MM-RemoteCluster-Id`: Remote cluster ID
- `X-MM-RemoteCluster-Token`: Authentication token
- Body: `RemoteClusterFrame` containing `SyncMsg`
**SyncMsg Structure:**
```json
{
"channel_id": "channel_123",
"users": {"user_id": {...}},
"posts": [{...}],
"reactions": [{...}],
"acknowledgements": [{...}],
"statuses": [{...}],
"mention_transforms": {"username": "user_id"}
}
```
**Receive Sync Message (Server B)**
- Validates authentication
- Processes users (creates synthetic users with obfuscated emails)
- Processes posts (transforms mentions, handles edits/deletes)
- Processes reactions (adds/removes)
- Processes acknowledgements
- Updates user statuses
- Returns success response with timestamps
#### 4. File Attachment Synchronization
**Upload Flow:**
1. Server A creates upload session
2. Sends upload creation message (topic: `sharedchannel_upload`)
3. Server B creates matching session
4. Server A streams file data: `POST /api/v4/remotecluster/upload/{upload_id}`
5. Server B saves file and creates FileInfo record
#### 5. Profile Image Synchronization
**Upload Flow:**
1. Detect user image update (`LastPictureUpdate` changed)
2. Server A uploads image: `POST /api/v4/remotecluster/{user_id}/image`
3. Server B validates user belongs to remote
4. Saves image and invalidates cache
#### 6. Membership Synchronization
**Feature Flag:** `EnableSharedChannelsMemberSync`
**Incremental Updates:**
- User added/removed from channel
- Sends `MembershipChangeMsg` in SyncMsg
- Receiving server adds/removes user accordingly
**Batch Member Sync:**
- Syncs all channel members in batches of 100
- Triggered on initial share or reconnection
- Only syncs local users (excludes synthetic remote users)
#### 7. Global User Synchronization
**Feature Flag:** `EnableSyncAllUsersForRemoteCluster`
**Purpose:** Sync all local users for better mention support
**Flow:**
1. Triggered on connection establishment or manual request
2. Collects users in batches of 25
3. Sends via topic `sharedchannel_global_user_sync`
4. Empty `channel_id` indicates global sync
5. Updates `LastGlobalUserSyncAt` cursor
### Authentication & Security
```mermaid
sequenceDiagram
participant SA as Server A
participant SB as Server B
Note over SA,SB: Token Setup During Connection
SA->>SA: Generate Token_A<br/>(for incoming auth)
SB->>SB: Generate Token_B<br/>(for incoming auth)
SA->>SB: Invitation contains Token_A
SB->>SA: Confirmation contains Token_B
SA->>SA: Store RemoteToken = Token_B
SB->>SB: Store RemoteToken = Token_A
Note over SA,SB: Server A sends message to Server B
SA->>SB: POST /api/v4/remotecluster/msg<br/>X-MM-RemoteCluster-Id: RemoteId_B<br/>X-MM-RemoteCluster-Token: Token_B
SB->>SB: Validate RemoteId_B exists
SB->>SB: Validate Token matches stored Token_B
alt Valid Token
SB->>SA: 200 OK + Response Data
else Invalid Token
SB->>SA: 401 Unauthorized
end
Note over SA,SB: Server B sends message to Server A
SB->>SA: POST /api/v4/remotecluster/msg<br/>X-MM-RemoteCluster-Id: RemoteId_A<br/>X-MM-RemoteCluster-Token: Token_A
SA->>SA: Validate RemoteId_A exists
SA->>SA: Validate Token matches stored Token_A
alt Valid Token
SA->>SB: 200 OK + Response Data
else Invalid Token
SA->>SB: 401 Unauthorized
end
```
**Token-Based Authentication:**
- Each server has `Token` (for incoming requests)
- Each server stores `RemoteToken` (for outgoing requests)
- All inter-server API calls include both in headers
**Invitation Encryption:**
- PBKDF2 key derivation (600,000 iterations)
- AES-GCM encryption
- Base64-encoded invite codes
**User Privacy:**
- Synthetic users with obfuscated data
- Username munged: `alice:remote-workspace`
- Email replaced with UUID
- Original values in Props (not exposed to clients)
### Key API Endpoints
**Remote Cluster Management:**
- `POST /api/v4/remotecluster` - Create remote (generate invite)
- `POST /api/v4/remotecluster/accept_invite` - Accept invitation
- `POST /api/v4/remotecluster/confirm_invite` - Confirm connection
- `POST /api/v4/remotecluster/ping` - Heartbeat
- `POST /api/v4/remotecluster/msg` - Message delivery (all topics)
**Shared Channel Management:**
- `GET /api/v4/sharedchannels/{team_id}` - List shared channels
- `POST /api/v4/channels/{channel_id}/remotes/{remote_id}/invite` - Share channel
- `POST /api/v4/channels/{channel_id}/remotes/{remote_id}/uninvite` - Unshare
**File Operations:**
- `POST /api/v4/remotecluster/upload/{upload_id}` - Upload file
- `POST /api/v4/remotecluster/{user_id}/image` - Upload profile image
### Error Handling & Monitoring
**Retry Logic:**
- Max 3 retries per sync task
- Exponential backoff with 2-second minimum delay
- Post-level retry for specific failures
**Offline Handling:**
- Queues pending invitations when remote offline
- Resumes sync when connection restored
- Notifies users with ephemeral messages
**Metrics:**
- `shared_channels_sync_counter` - Sync attempts
- `shared_channels_queue_size` - Queue depth
- `remote_cluster_msg_sent` - Successful messages
- `remote_cluster_msg_errors` - Failed messages
### Key Files
**Models:** `server/public/model/remote_cluster.go`, `server/public/model/shared_channel.go`
**API:** `server/channels/api4/remote_cluster.go`, `server/channels/api4/shared_channel.go`
**Services:**
- `server/platform/services/remotecluster/` - Connection management
- `server/platform/services/sharedchannel/` - Sync logic
This architecture enables secure, bidirectional synchronization of channels between independent Mattermost instances while maintaining data privacy and consistency.

View File

@@ -0,0 +1,210 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sharedchannel
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"sync"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/platform/services/remotecluster"
)
// postsToAttachments returns the file attachments for a slice of posts that need to be synchronized.
func (scs *Service) shouldSyncAttachment(fi *model.FileInfo, rc *model.RemoteCluster) bool {
sca, err := scs.server.GetStore().SharedChannel().GetAttachment(fi.Id, rc.RemoteId)
if err != nil {
if _, ok := err.(errNotFound); !ok {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "error fetching shared channel attachment",
mlog.String("file_id", fi.Id),
mlog.String("remote_id", rc.RemoteId),
mlog.Err(err),
)
}
// no record so sync is needed
return true
}
return sca.LastSyncAt < fi.UpdateAt
}
// sendAttachmentForRemote asynchronously sends a file attachment to a remote cluster.
func (scs *Service) sendAttachmentForRemote(fi *model.FileInfo, post *model.Post, rc *model.RemoteCluster) error {
rcs := scs.server.GetRemoteClusterService()
if rcs == nil {
return fmt.Errorf("cannot update remote cluster for remote id %s; Remote Cluster Service not enabled", rc.RemoteId)
}
if rc.IsPlugin() {
return scs.sendAttachmentToPlugin(fi, post, rc)
}
us := &model.UploadSession{
Id: model.NewId(),
Type: model.UploadTypeAttachment,
UserId: post.UserId,
ChannelId: post.ChannelId,
Filename: fi.Name,
FileSize: fi.Size,
RemoteId: rc.RemoteId,
ReqFileId: fi.Id,
}
payload, err := json.Marshal(us)
if err != nil {
return err
}
msg := model.NewRemoteClusterMsg(TopicUploadCreate, payload)
ctx, cancel := context.WithTimeout(context.Background(), remotecluster.SendTimeout)
defer cancel()
var usResp model.UploadSession
var respErr error
var wg sync.WaitGroup
wg.Add(1)
// creating the upload session on the remote server needs to be done synchronously.
err = rcs.SendMsg(ctx, msg, rc, func(msg model.RemoteClusterMsg, rc *model.RemoteCluster, resp *remotecluster.Response, err error) {
defer wg.Done()
if err != nil {
respErr = err
return
}
if !resp.IsSuccess() {
respErr = errors.New(resp.Err)
return
}
respErr = json.Unmarshal(resp.Payload, &usResp)
})
if err != nil {
return fmt.Errorf("error sending create upload session to remote %s for post %s: %w", rc.RemoteId, post.Id, err)
}
wg.Wait()
if respErr != nil {
return fmt.Errorf("invalid create upload session response for remote %s and post %s: %w", rc.RemoteId, post.Id, respErr)
}
ctx2, cancel2 := context.WithTimeout(context.Background(), remotecluster.SendFileTimeout)
defer cancel2()
return rcs.SendFile(ctx2, &usResp, fi, rc, scs.app, func(us *model.UploadSession, rc *model.RemoteCluster, resp *remotecluster.Response, err error) {
if err != nil {
return // this means the response could not be parsed; already logged
}
if !resp.IsSuccess() {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "send file failed",
mlog.String("remote", rc.DisplayName),
mlog.String("upload_id", usResp.Id),
mlog.String("err", resp.Err),
)
return
}
// response payload should be a model.FileInfo.
var fi model.FileInfo
if err2 := json.Unmarshal(resp.Payload, &fi); err2 != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "invalid file info response after send file",
mlog.String("remote", rc.DisplayName),
mlog.String("upload_id", usResp.Id),
mlog.Err(err2),
)
return
}
// save file attachment record in SharedChannelAttachments table
if err2 := scs.saveSharedAttachment(&fi, rc); err2 != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "error saving SharedChannelAttachment",
mlog.String("remote", rc.DisplayName),
mlog.String("upload_id", usResp.Id),
mlog.Err(err2),
)
return
}
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "send file successful",
mlog.String("remote", rc.DisplayName),
mlog.String("upload_id", usResp.Id),
)
})
}
// sendAttachmentToPlugin asynchronously sends a file attachment to a remote cluster.
func (scs *Service) sendAttachmentToPlugin(fi *model.FileInfo, post *model.Post, rc *model.RemoteCluster) error {
if err := scs.app.OnSharedChannelsAttachmentSyncMsg(fi, post, rc); err != nil {
return fmt.Errorf("cannot send attachment to plugin %s: %w", rc.PluginID, err)
}
return scs.saveSharedAttachment(fi, rc)
}
// saveSharedAttachment saves the attachment in SharedChannelAttachments table.
func (scs *Service) saveSharedAttachment(fi *model.FileInfo, rc *model.RemoteCluster) error {
sca := &model.SharedChannelAttachment{
FileId: fi.Id,
RemoteId: rc.RemoteId,
}
_, err := scs.server.GetStore().SharedChannel().UpsertAttachment(sca)
return err
}
// onReceiveUploadCreate is called when a message requesting to create an upload session is received. An upload session is
// created and the id returned in the response.
func (scs *Service) onReceiveUploadCreate(msg model.RemoteClusterMsg, rc *model.RemoteCluster, response *remotecluster.Response) error {
var us model.UploadSession
if err := json.Unmarshal(msg.Payload, &us); err != nil {
return fmt.Errorf("invalid upload session request: %w", err)
}
// make sure channel is shared for the remote sender
hasRemote, err := scs.server.GetStore().SharedChannel().HasRemote(us.ChannelId, rc.RemoteId)
if err != nil {
return fmt.Errorf("could not validate upload session for remote: %w", err)
}
if !hasRemote {
return model.NewAppError("createUpload", "api.upload.create.upload_channel_not_shared_with_remote.app_error", nil, "", http.StatusBadRequest)
}
// make sure file attachments are enabled
if scs.server.Config().FileSettings.EnableFileAttachments == nil || !*scs.server.Config().FileSettings.EnableFileAttachments {
return model.NewAppError("ReceiveUploadCreate",
"api.file.attachments.disabled.app_error",
nil, "", http.StatusNotImplemented)
}
// make sure the file size requested does not exceed local server config - MM server's regular
// upload code will ensure the actual bytes sent are within the upload session limit.
if scs.server.Config().FileSettings.MaxFileSize == nil || us.FileSize > *scs.server.Config().FileSettings.MaxFileSize {
return model.NewAppError("createUpload", "api.upload.create.upload_too_large.app_error",
map[string]any{"channelId": us.ChannelId}, "", http.StatusRequestEntityTooLarge)
}
// validate upload type for shared channels - only allow attachments
if us.Type != model.UploadTypeAttachment {
return model.NewAppError("onReceiveUploadCreate", "api.upload.invalid_type_for_shared_channel.app_error",
nil, "", http.StatusBadRequest)
}
us.RemoteId = rc.RemoteId // don't let remotes try to impersonate each other
// create upload session.
usSaved, appErr := scs.app.CreateUploadSession(request.EmptyContext(scs.server.Log()), &us)
if appErr != nil {
return appErr
}
response.SetPayload(usSaved)
return nil
}

View File

@@ -0,0 +1,601 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sharedchannel
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/platform/services/remotecluster"
)
// channelInviteMsg represents an invitation for a remote cluster to start sharing a channel.
type channelInviteMsg struct {
ChannelId string `json:"channel_id"`
TeamId string `json:"team_id"` // Deprecated: this field is no longer used. It's only kept for backwards compatibility.
ReadOnly bool `json:"read_only"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Header string `json:"header"`
Purpose string `json:"purpose"`
Type model.ChannelType `json:"type"`
CreatorID string `json:"creator_id"`
DirectParticipantIDs []string `json:"direct_participant_ids"`
DirectParticipants []*model.User `json:"direct_participants"`
}
func (cim channelInviteMsg) DirectParticipantsMap() map[string]*model.User {
dim := make(map[string]*model.User)
for _, user := range cim.DirectParticipants {
dim[user.Id] = user
}
return dim
}
type InviteOption func(msg *channelInviteMsg)
func WithDirectParticipant(participant *model.User, remoteID string) InviteOption {
return func(msg *channelInviteMsg) {
msg.DirectParticipantIDs = append(msg.DirectParticipantIDs, participant.Id)
// if the participant doesn't belong to the remote we're
// sending the invite to, send it as part of the invite
// payload
if participant.GetRemoteID() != remoteID {
msg.DirectParticipants = append(msg.DirectParticipants, sanitizeUserForSync(participant))
}
}
}
func WithCreator(creatorID string) InviteOption {
return func(msg *channelInviteMsg) {
msg.CreatorID = creatorID
}
}
// SendChannelInvite asynchronously sends a channel invite to a remote cluster. The remote cluster is
// expected to create a new channel with the same channel id, and respond with status OK.
// If an error occurs on the remote cluster then an ephemeral message is posted in the channel for userId.
func (scs *Service) SendChannelInvite(channel *model.Channel, userId string, rc *model.RemoteCluster, options ...InviteOption) error {
rcs := scs.server.GetRemoteClusterService()
if rcs == nil {
return fmt.Errorf("cannot invite remote cluster for channel id %s; Remote Cluster Service not enabled", channel.Id)
}
sc, err := scs.server.GetStore().SharedChannel().Get(channel.Id)
if err != nil {
return err
}
// if the remote is not currently online, we store the invite to
// send it when the connection is restored
if !rc.IsOnline() {
if len(options) > 0 {
// pending invites with options are currently not supported
scs.sendEphemeralPost(channel.Id, userId, fmt.Sprintf("Error sending channel invite for %s: %s", rc.DisplayName, model.ErrOfflineRemote))
return model.ErrOfflineRemote
}
existingScr, getErr := scs.server.GetStore().SharedChannel().GetRemoteByIds(sc.ChannelId, rc.RemoteId)
var errNotFound *store.ErrNotFound
if getErr != nil && !errors.As(getErr, &errNotFound) {
scs.sendEphemeralPost(channel.Id, userId, fmt.Sprintf("Error sending channel invite for %s: %v", rc.DisplayName, getErr))
return getErr
}
if getErr != nil {
existingScr = nil // ErrNotFound: no record, will insert
}
if existingScr != nil {
// If the record is not soft deleted, the remote is already connected — error out
if existingScr.DeleteAt == 0 {
scs.sendEphemeralPost(channel.Id, userId, fmt.Sprintf("Error sending channel invite for %s: %s", rc.DisplayName, model.ErrChannelAlreadyShared))
return model.ErrChannelAlreadyShared
}
// restore previously uninvited (soft-deleted) record instead of inserting
curTime := model.GetMillis()
existingScr.DeleteAt = 0
existingScr.UpdateAt = curTime
existingScr.CreatorId = userId
existingScr.IsInviteAccepted = true
existingScr.IsInviteConfirmed = false
existingScr.LastMembersSyncAt = 0
if _, err = scs.server.GetStore().SharedChannel().UpdateRemote(existingScr); err != nil {
scs.sendEphemeralPost(channel.Id, userId, fmt.Sprintf("Error saving channel invite for %s: %v", rc.DisplayName, err))
return err
}
} else {
scr := &model.SharedChannelRemote{
ChannelId: sc.ChannelId,
CreatorId: userId,
RemoteId: rc.RemoteId,
IsInviteAccepted: true,
IsInviteConfirmed: false,
LastMembersSyncAt: 0,
}
if _, err = scs.server.GetStore().SharedChannel().SaveRemote(scr); err != nil {
scs.sendEphemeralPost(channel.Id, userId, fmt.Sprintf("Error saving channel invite for %s: %v", rc.DisplayName, err))
return err
}
}
return nil
}
invite := channelInviteMsg{
ChannelId: channel.Id,
ReadOnly: sc.ReadOnly,
Name: channel.Name,
DisplayName: sc.ShareDisplayName,
Header: sc.ShareHeader,
Purpose: sc.SharePurpose,
Type: channel.Type,
}
for _, option := range options {
option(&invite)
}
json, err := json.Marshal(invite)
if err != nil {
return err
}
msg := model.NewRemoteClusterMsg(TopicChannelInvite, json)
// onInvite is called after invite is sent, whether to a remote cluster or plugin.
onInvite := func(_ model.RemoteClusterMsg, rc *model.RemoteCluster, resp *remotecluster.Response, err error) {
if err != nil || !resp.IsSuccess() {
scs.sendEphemeralPost(channel.Id, userId, fmt.Sprintf("Error sending channel invite for %s: %s", rc.DisplayName, combineErrors(err, resp.Err)))
return
}
existingScr, err := scs.server.GetStore().SharedChannel().GetRemoteByIds(sc.ChannelId, rc.RemoteId)
var errNotFound *store.ErrNotFound
if err != nil && !errors.As(err, &errNotFound) {
scs.sendEphemeralPost(channel.Id, userId, fmt.Sprintf("Error sending channel invite for %s: %s", rc.DisplayName, err))
return
}
curTime := model.GetMillis()
if existingScr != nil {
if existingScr.DeleteAt == 0 && existingScr.IsInviteConfirmed {
// the shared channel remote exists and is not
// deleted, nothing to do here
return
}
// the shared channel remote was deleted in the past or
// pending confirmation, so with the new invite we restore
// it
existingScr.DeleteAt = 0
existingScr.UpdateAt = curTime
existingScr.LastPostCreateAt = curTime
existingScr.LastPostUpdateAt = curTime
existingScr.IsInviteConfirmed = true
if _, sErr := scs.server.GetStore().SharedChannel().UpdateRemote(existingScr); sErr != nil {
scs.sendEphemeralPost(channel.Id, userId, fmt.Sprintf("Error confirming channel invite for %s: %v", rc.DisplayName, sErr))
return
}
} else {
// the shared channel remote doesn't exists, so we create it
scr := &model.SharedChannelRemote{
ChannelId: sc.ChannelId,
CreatorId: userId,
RemoteId: rc.RemoteId,
IsInviteAccepted: true,
IsInviteConfirmed: true,
LastPostCreateAt: curTime,
LastPostUpdateAt: curTime,
LastMembersSyncAt: 0,
}
if _, err = scs.server.GetStore().SharedChannel().SaveRemote(scr); err != nil {
scs.sendEphemeralPost(channel.Id, userId, fmt.Sprintf("Error confirming channel invite for %s: %v", rc.DisplayName, err))
return
}
}
scs.NotifyChannelChanged(sc.ChannelId)
// Notify clients that shared channel remotes have been updated so the UI
// can refresh its cached remote names for this channel.
messageWs := model.NewWebSocketEvent(model.WebsocketEventSharedChannelRemoteUpdated, "", sc.ChannelId, "", nil, "")
messageWs.Add("channel_id", sc.ChannelId)
scs.app.Publish(messageWs)
scs.postChannelSharedWithWorkspace(channel, rc)
// Trigger membership sync via the normal sync pipeline (reads from ChannelMemberHistory)
scs.NotifyMembershipChanged(sc.ChannelId, "")
}
if rc.IsPlugin() {
// for now plugins are considered fully invited automatically
// TODO: MM-57537 create plugin hook that passes invitation to plugins if BitflagOptionAutoInvited is not set
onInvite(msg, rc, &remotecluster.Response{Status: remotecluster.ResponseStatusOK}, nil)
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), remotecluster.SendTimeout)
defer cancel()
return rcs.SendMsg(ctx, msg, rc, onInvite)
}
func combineErrors(err error, serror string) string {
var sb strings.Builder
if err != nil {
sb.WriteString(err.Error())
}
if serror != "" {
if sb.Len() > 0 {
sb.WriteString("; ")
}
sb.WriteString(serror)
}
return sb.String()
}
func (scs *Service) onReceiveChannelInvite(msg model.RemoteClusterMsg, rc *model.RemoteCluster, _ *remotecluster.Response) error {
if len(msg.Payload) == 0 {
return nil
}
var invite channelInviteMsg
if err := json.Unmarshal(msg.Payload, &invite); err != nil {
return fmt.Errorf("invalid channel invite: %w", err)
}
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Channel invite received",
mlog.String("remote", rc.DisplayName),
mlog.String("channel_id", invite.ChannelId),
mlog.String("channel_name", invite.Name),
mlog.String("team_id", invite.TeamId),
)
// check if channel already exists
existingScr, err := scs.server.GetStore().SharedChannel().GetRemoteByIds(invite.ChannelId, rc.RemoteId)
var errNotFound *store.ErrNotFound
if err != nil && !errors.As(err, &errNotFound) {
return fmt.Errorf("cannot get deleted shared channel remote (channel_id=%s): %w", invite.ChannelId, err)
}
if existingScr != nil && existingScr.DeleteAt == 0 {
// the channel is already shared, nothing to do
return nil
}
var channel *model.Channel
var created bool
if existingScr == nil {
var err error
_, err = scs.server.GetStore().Channel().Get(invite.ChannelId, true)
if err == nil {
// the channel already exists on this server and was not
// previously shared, so we reject the invite
return fmt.Errorf("cannot create new shared channel (channel_id=%s): %w", invite.ChannelId, model.ErrChannelAlreadyExists)
}
// create new local channel to sync with the remote channel
if channel, created, err = scs.handleChannelCreation(invite, rc); err != nil {
return err
}
// sanity check to ensure the channel returned has the expected id. Otherwise sync will not work as expected and will fail
// silently.
if invite.ChannelId != channel.Id {
// as of this writing, this scenario should only be possible if the invite included a DM or GM channel
// invitation with a combination of user ids that already have a DM or GM on this server. Very unlikely
// unless the remote is compromised AND has knowledge of the local user ids.
// Another possibility would be an actual user ID collision between two servers, where the likelihood is
// infinitesimally small
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "Channel invite failed - channel created/fetched with wrong id",
mlog.String("remote", rc.DisplayName),
mlog.String("channel_id", invite.ChannelId),
mlog.String("channel_type", invite.Type),
mlog.String("channel_name", invite.Name),
mlog.String("team_id", invite.TeamId),
mlog.Array("dm_partics", invite.DirectParticipantIDs),
)
return fmt.Errorf("cannot create shared channel (channel_id=%s channel_type=%s): %w", invite.ChannelId, invite.Type, model.ErrChannelAlreadyExists)
}
// mark the newly created channel read-only if requested in the invite
if invite.ReadOnly {
if err := scs.makeChannelReadOnly(channel); err != nil {
return fmt.Errorf("cannot make channel readonly `%s`: %w", invite.ChannelId, err)
}
}
} else {
var err error
channel, err = scs.server.GetStore().Channel().Get(invite.ChannelId, true)
if err != nil {
return fmt.Errorf("cannot get channel (channel_id=%s) to restore a shared channel remote: %w", invite.ChannelId, err)
}
}
sharedChannel := &model.SharedChannel{
ChannelId: channel.Id,
TeamId: channel.TeamId,
Home: false,
ReadOnly: existingScr == nil && invite.ReadOnly, // only set read only flag for new shares
ShareName: channel.Name,
ShareDisplayName: channel.DisplayName,
SharePurpose: channel.Purpose,
ShareHeader: channel.Header,
CreatorId: rc.CreatorId,
RemoteId: rc.RemoteId,
Type: channel.Type,
}
if _, err := scs.server.GetStore().SharedChannel().Save(sharedChannel); err != nil {
// delete the newly created channel since we could not create a SharedChannel record for it
if created {
scs.app.PermanentDeleteChannel(request.EmptyContext(scs.server.Log()), channel)
}
return fmt.Errorf("cannot create shared channel (channel_id=%s): %w", invite.ChannelId, err)
}
curTime := model.GetMillis()
if existingScr != nil {
existingScr.DeleteAt = 0
existingScr.UpdateAt = curTime
existingScr.LastPostCreateAt = curTime
existingScr.LastPostUpdateAt = curTime
if _, err := scs.server.GetStore().SharedChannel().UpdateRemote(existingScr); err != nil {
return fmt.Errorf("cannot restore deleted shared channel remote (channel_id=%s): %w", invite.ChannelId, err)
}
// Trigger membership sync via the normal sync pipeline (reads from ChannelMemberHistory)
scs.NotifyMembershipChanged(channel.Id, "")
} else {
creatorID := channel.CreatorId
if creatorID == "" {
creatorID = invite.CreatorID
}
scr := &model.SharedChannelRemote{
Id: model.NewId(),
ChannelId: channel.Id,
CreatorId: creatorID,
IsInviteAccepted: true,
IsInviteConfirmed: true,
RemoteId: rc.RemoteId,
LastPostCreateAt: model.GetMillis(),
LastPostUpdateAt: model.GetMillis(),
LastMembersSyncAt: 0,
}
if _, err := scs.server.GetStore().SharedChannel().SaveRemote(scr); err != nil {
// delete the newly created channel since we could not create a SharedChannelRemote record for it,
// and delete the newly created SharedChannel record as well.
if created {
scs.app.PermanentDeleteChannel(request.EmptyContext(scs.server.Log()), channel)
}
scs.server.GetStore().SharedChannel().Delete(sharedChannel.ChannelId)
return fmt.Errorf("cannot create shared channel remote (channel_id=%s): %w", invite.ChannelId, err)
}
// Trigger membership sync via the normal sync pipeline (reads from ChannelMemberHistory)
scs.NotifyMembershipChanged(channel.Id, "")
}
scs.postChannelSharedWithWorkspace(channel, rc)
return nil
}
// handleChannelCreation creates a new channel to represent the remote channel in the invitation.
// For DMs there is a chance the channel already exists (shared, unshared, shared again) and the boolean
// determines if the channel was newly created (true=new)
func (scs *Service) handleChannelCreation(invite channelInviteMsg, rc *model.RemoteCluster) (*model.Channel, bool, error) {
if invite.Type == model.ChannelTypeDirect {
return scs.createDirectChannel(invite, rc)
}
if invite.Type == model.ChannelTypeGroup {
return scs.createGroupChannel(invite, rc)
}
teamId := rc.DefaultTeamId
// if the remote doesn't have a teamId associated and until the
// acceptance of an invite includes selecting a team, we use the
// first team of the list
if teamId == "" {
teams, err := scs.server.GetStore().Team().GetAllPage(0, 1, nil)
if err != nil {
return nil, false, fmt.Errorf("cannot get team to create the channel `%s`: %w", invite.ChannelId, err)
}
teamId = teams[0].Id
}
channelNew := &model.Channel{
Id: invite.ChannelId,
TeamId: teamId,
Type: invite.Type,
DisplayName: invite.DisplayName,
Name: invite.Name,
Header: invite.Header,
Purpose: invite.Purpose,
CreatorId: rc.CreatorId,
Shared: new(true),
}
// check user perms?
channel, appErr := scs.app.CreateChannelWithUser(request.EmptyContext(scs.server.Log()), channelNew, rc.CreatorId)
if appErr != nil {
return nil, false, fmt.Errorf("cannot create channel `%s`: %w", invite.ChannelId, appErr)
}
return channel, true, nil
}
// getOrCreateUser will try to fetch a user by its ID from the
// database and if it fails, it will try to create it if is present in
// the participantsMap
func (scs *Service) getOrCreateUser(userID string, participantsMap map[string]*model.User, rc *model.RemoteCluster) (*model.User, error) {
user, err := scs.server.GetStore().User().Get(request.EmptyContext(scs.server.Log()), userID)
if err == nil {
return user, nil
}
inviteUser, ok := participantsMap[userID]
if !ok {
// at this point we couldn't fetch the user nor we can create
// it from the invite information, so we return an error
return nil, fmt.Errorf("cannot fetch user `%q`: %w", userID, err)
}
var rctx request.CTX = request.EmptyContext(scs.server.Log())
inviteUser.RemoteId = new(rc.RemoteId)
user, iErr := scs.insertSyncUser(rctx, inviteUser, nil, rc)
if iErr != nil {
return nil, fmt.Errorf("cannot create user `%q` for remote `%q`: %w", inviteUser.Id, rc.RemoteId, iErr)
}
return user, nil
}
// createDirectChannel creates a DM channel, or fetches an existing channel, and returns the channel plus a boolean
// indicating if the channel is new.
func (scs *Service) createDirectChannel(invite channelInviteMsg, rc *model.RemoteCluster) (*model.Channel, bool, error) {
if len(invite.DirectParticipantIDs) != 2 {
return nil, false, fmt.Errorf("cannot create direct channel `%s` insufficient participant count `%d`", invite.ChannelId, len(invite.DirectParticipantIDs))
}
participantsMap := invite.DirectParticipantsMap()
user1, err := scs.getOrCreateUser(invite.DirectParticipantIDs[0], participantsMap, rc)
if err != nil {
return nil, false, fmt.Errorf("cannot create direct channel `%s` from invite: %w", invite.ChannelId, err)
}
user2, err := scs.getOrCreateUser(invite.DirectParticipantIDs[1], participantsMap, rc)
if err != nil {
return nil, false, fmt.Errorf("cannot create direct channel `%s` from invite: %w", invite.ChannelId, err)
}
// determine the remote user
// - if both are remote then the DM channel does not belong on this server
// - if neither are remote then the DM channel should not be created via sync message
// - if only one is remote then we check visibility relative to that user
userRemote := user1
userLocal := user2
if !userRemote.IsRemote() {
userRemote = user2
userLocal = user1
}
if !userRemote.IsRemote() {
return nil, false, fmt.Errorf("cannot create direct channel `%s` remote user is not remote (%s)", invite.ChannelId, userRemote.Id)
}
if userLocal.IsRemote() {
return nil, false, fmt.Errorf("cannot create direct channel `%s` local user is not local (%s)", invite.ChannelId, userLocal.Id)
}
if userRemote.GetRemoteID() != rc.RemoteId {
return nil, false, fmt.Errorf("cannot create direct channel `%s`: %w", invite.ChannelId, ErrRemoteIDMismatch)
}
// ensure remote user is allowed to DM the local user
canSee, appErr := scs.app.UserCanSeeOtherUser(request.EmptyContext(scs.server.Log()), userRemote.Id, userLocal.Id)
if appErr != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "cannot check user visibility for DM creation",
mlog.String("user_remote", userRemote.Id),
mlog.String("user_local", userLocal.Id),
mlog.String("channel_id", invite.ChannelId),
mlog.Err(appErr),
)
return nil, false, fmt.Errorf("cannot check user visibility for DM (%s) creation: %w", invite.ChannelId, appErr)
}
if !canSee {
return nil, false, fmt.Errorf("cannot create direct channel `%s`: %w", invite.ChannelId, ErrUserDMPermission)
}
// check if this DM already exists.
channelName := model.GetDMNameFromIds(userRemote.Id, userLocal.Id)
channelExists, err := scs.server.GetStore().Channel().GetByName("", channelName, true)
if err != nil && !isNotFoundError(err) {
return nil, false, fmt.Errorf("cannot check DM channel exists (%s): %w", channelName, err)
}
if channelExists != nil {
if channelExists.Id == invite.ChannelId {
return channelExists, false, nil
}
return nil, false, fmt.Errorf("cannot create direct channel `%s`: channel exists with wrong id", channelName)
}
// create the channel
channel, appErr := scs.app.GetOrCreateDirectChannel(request.EmptyContext(scs.server.Log()), userRemote.Id, userLocal.Id, model.WithID(invite.ChannelId))
if appErr != nil {
return nil, false, fmt.Errorf("cannot create direct channel `%s`: %w", invite.ChannelId, appErr)
}
return channel, true, nil
}
// createGroupChannel creates a DM channel, or fetches an existing channel, and returns the channel plus a boolean
// indicating if the channel is new.
func (scs *Service) createGroupChannel(invite channelInviteMsg, rc *model.RemoteCluster) (*model.Channel, bool, error) {
if len(invite.DirectParticipantIDs) > model.ChannelGroupMaxUsers || len(invite.DirectParticipantIDs) < model.ChannelGroupMinUsers {
return nil, false, fmt.Errorf("cannot create group channel `%s` bad participant count `%d`", invite.ChannelId, len(invite.DirectParticipantIDs))
}
participantsMap := invite.DirectParticipantsMap()
remoteIDMap := map[string]bool{}
hasLocalUsers := false
for _, participantID := range invite.DirectParticipantIDs {
user, err := scs.getOrCreateUser(participantID, participantsMap, rc)
if err != nil {
return nil, false, fmt.Errorf("cannot create group channel `%s` from invite: %w", invite.ChannelId, err)
}
// we keep track of the origin of the users to check if the
// invite is valid
if user.IsRemote() {
remoteIDMap[user.GetRemoteID()] = true
} else {
hasLocalUsers = true
}
}
// if the invite doesn't contain remote users, GM should not be created via remote invite
if len(remoteIDMap) == 0 {
return nil, false, fmt.Errorf("cannot create group channel `%s` there are no remote users", invite.ChannelId)
}
// if the channel doesn't contain local users, the GM channel doesn't belong to this server
if !hasLocalUsers {
return nil, false, fmt.Errorf("cannot create group channel `%s` there are no local users", invite.ChannelId)
}
// check if this GM already exists.
channelName := model.GetGroupNameFromUserIds(invite.DirectParticipantIDs)
channelExists, err := scs.server.GetStore().Channel().GetByName("", channelName, true)
if err != nil && !isNotFoundError(err) {
return nil, false, fmt.Errorf("cannot check GM channel exists (%s): %w", channelName, err)
}
if channelExists != nil {
if channelExists.Id == invite.ChannelId {
return channelExists, false, nil
}
return nil, false, fmt.Errorf("cannot create group channel `%s`: channel exists with wrong id", channelName)
}
// create the channel
channel, appErr := scs.app.CreateGroupChannel(request.EmptyContext(scs.server.Log()), invite.DirectParticipantIDs, invite.CreatorID, model.WithID(invite.ChannelId))
if appErr != nil {
return nil, false, fmt.Errorf("cannot create group channel `%s`: %w", invite.ChannelId, appErr)
}
return channel, true, nil
}

View File

@@ -0,0 +1,527 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sharedchannel
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin/plugintest/mock"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
"github.com/mattermost/mattermost/server/v8/platform/services/remotecluster"
)
var (
mockTypeChannel = mock.AnythingOfType("*model.Channel")
mockTypeUser = mock.AnythingOfType("*model.User")
mockTypeString = mock.AnythingOfType("string")
mockTypeReqContext = mock.AnythingOfType("*request.Context")
mockTypeContext = mock.MatchedBy(func(ctx request.CTX) bool { return true })
)
// stubRemoteClusterService is a no-op implementation of RemoteClusterServiceIFace for tests
// that only need to satisfy the non-nil check in SendChannelInvite (remote offline path).
type stubRemoteClusterService struct{}
func (s *stubRemoteClusterService) Shutdown() error { return nil }
func (s *stubRemoteClusterService) Start() error { return nil }
func (s *stubRemoteClusterService) Active() bool { return false }
func (s *stubRemoteClusterService) AddTopicListener(topic string, listener remotecluster.TopicListener) string {
return ""
}
func (s *stubRemoteClusterService) RemoveTopicListener(listenerId string) {}
func (s *stubRemoteClusterService) AddConnectionStateListener(listener remotecluster.ConnectionStateListener) string {
return ""
}
func (s *stubRemoteClusterService) RemoveConnectionStateListener(listenerId string) {}
func (s *stubRemoteClusterService) SendMsg(ctx context.Context, msg model.RemoteClusterMsg, rc *model.RemoteCluster, f remotecluster.SendMsgResultFunc) error {
return nil
}
func (s *stubRemoteClusterService) SendFile(ctx context.Context, us *model.UploadSession, fi *model.FileInfo, rc *model.RemoteCluster, rp remotecluster.ReaderProvider, f remotecluster.SendFileResultFunc) error {
return nil
}
func (s *stubRemoteClusterService) SendProfileImage(ctx context.Context, userID string, rc *model.RemoteCluster, provider remotecluster.ProfileImageProvider, f remotecluster.SendProfileImageResultFunc) error {
return nil
}
func (s *stubRemoteClusterService) AcceptInvitation(invite *model.RemoteClusterInvite, name string, displayName string, creatorId string, siteURL string, defaultTeamId string) (*model.RemoteCluster, error) {
return nil, nil
}
func (s *stubRemoteClusterService) ReceiveIncomingMsg(rc *model.RemoteCluster, msg model.RemoteClusterMsg) remotecluster.Response {
return remotecluster.Response{}
}
func (s *stubRemoteClusterService) ReceiveInviteConfirmation(invite model.RemoteClusterInvite) (*model.RemoteCluster, error) {
return nil, nil
}
func (s *stubRemoteClusterService) PingNow(rc *model.RemoteCluster) {}
func (s *stubRemoteClusterService) NotifySyncFailed(remoteId string) {}
var _ remotecluster.RemoteClusterServiceIFace = (*stubRemoteClusterService)(nil)
// setupMockServerWithConfig sets up the standard mocks that all tests need
func setupMockServerWithConfig(mockServer *MockServerIface) {
mockConfig := model.Config{}
mockConfig.SetDefaults()
mockServer.On("Config").Return(&mockConfig)
mockServer.On("GetRemoteClusterService").Return(nil)
}
func TestOnReceiveChannelInvite(t *testing.T) {
t.Run("when msg payload is empty, it does nothing", func(t *testing.T) {
mockServer := &MockServerIface{}
logger := mlog.CreateConsoleTestLogger(t)
mockServer.On("Log").Return(logger)
mockApp := &MockAppIface{}
scs := &Service{
server: mockServer,
app: mockApp,
}
mockStore := &mocks.Store{}
mockServer = scs.server.(*MockServerIface)
mockServer.On("GetStore").Return(mockStore)
remoteCluster := &model.RemoteCluster{}
msg := model.RemoteClusterMsg{}
err := scs.onReceiveChannelInvite(msg, remoteCluster, nil)
require.NoError(t, err)
mockStore.AssertNotCalled(t, "Channel")
})
t.Run("when invitation prescribes a readonly channel, it does create a readonly channel", func(t *testing.T) {
mockServer := &MockServerIface{}
logger := mlog.CreateConsoleTestLogger(t)
mockServer.On("Log").Return(logger)
mockApp := &MockAppIface{}
scs := &Service{
server: mockServer,
app: mockApp,
}
mockStore := &mocks.Store{}
remoteCluster := &model.RemoteCluster{RemoteId: model.NewId(), Name: "test", DefaultTeamId: model.NewId()}
invitation := channelInviteMsg{
ChannelId: model.NewId(),
TeamId: model.NewId(),
ReadOnly: true,
Type: model.ChannelTypeOpen,
}
payload, err := json.Marshal(invitation)
require.NoError(t, err)
msg := model.RemoteClusterMsg{
Payload: payload,
}
mockChannelStore := mocks.ChannelStore{}
mockSharedChannelStore := mocks.SharedChannelStore{}
channel := &model.Channel{
Id: invitation.ChannelId,
TeamId: invitation.TeamId,
Type: invitation.Type,
CreatorId: model.NewId(),
}
mockSharedChannelStore.On("GetRemoteByIds", invitation.ChannelId, remoteCluster.RemoteId).Return(nil, store.NewErrNotFound("SharedChannelRemote", ""))
mockChannelStore.On("Get", invitation.ChannelId, true).Return(nil, &store.ErrNotFound{})
mockSharedChannelStore.On("Save", mock.Anything).Return(nil, nil)
mockSharedChannelStore.On("SaveRemote", mock.Anything).Return(nil, nil)
mockStore.On("Channel").Return(&mockChannelStore)
mockStore.On("SharedChannel").Return(&mockSharedChannelStore)
mockServer.On("GetStore").Return(mockStore)
setupMockServerWithConfig(mockServer)
createPostPermission := model.ChannelModeratedPermissionsMap[model.PermissionCreatePost.Id]
createReactionPermission := model.ChannelModeratedPermissionsMap[model.PermissionAddReaction.Id]
updateMap := model.ChannelModeratedRolesPatch{
Guests: new(false),
Members: new(false),
}
mockApp.On("CreateChannelWithUser", mockTypeReqContext, mockTypeChannel, mockTypeString).Return(channel, nil)
readonlyChannelModerations := []*model.ChannelModerationPatch{
{
Name: &createPostPermission,
Roles: &updateMap,
},
{
Name: &createReactionPermission,
Roles: &updateMap,
},
}
mockApp.On("PatchChannelModerationsForChannel", mock.Anything, channel, readonlyChannelModerations).Return(nil, nil).Maybe()
mockApp.On("GetSystemBot", mockTypeReqContext).Return(&model.Bot{UserId: model.NewId()}, nil).Once()
mockApp.On("CreatePost", mockTypeReqContext, mock.AnythingOfType("*model.Post"), mockTypeChannel, mock.AnythingOfType("model.CreatePostFlags")).Return(&model.Post{Id: model.NewId()}, false, nil).Once()
defer mockApp.AssertExpectations(t)
err = scs.onReceiveChannelInvite(msg, remoteCluster, nil)
require.NoError(t, err)
})
t.Run("when invitation prescribes a readonly channel and readonly update fails, it returns an error", func(t *testing.T) {
mockServer := &MockServerIface{}
logger := mlog.CreateConsoleTestLogger(t)
mockServer.On("Log").Return(logger)
mockApp := &MockAppIface{}
scs := &Service{
server: mockServer,
app: mockApp,
}
mockStore := &mocks.Store{}
remoteCluster := &model.RemoteCluster{RemoteId: model.NewId(), Name: "test2"}
invitation := channelInviteMsg{
ChannelId: model.NewId(),
TeamId: model.NewId(),
ReadOnly: true,
Type: "0",
}
payload, err := json.Marshal(invitation)
require.NoError(t, err)
msg := model.RemoteClusterMsg{
Payload: payload,
}
mockChannelStore := mocks.ChannelStore{}
channel := &model.Channel{
Id: invitation.ChannelId,
}
mockTeamStore := mocks.TeamStore{}
team := &model.Team{
Id: model.NewId(),
}
mockSharedChannelStore := mocks.SharedChannelStore{}
mockSharedChannelStore.On("GetRemoteByIds", invitation.ChannelId, remoteCluster.RemoteId).Return(nil, store.NewErrNotFound("SharedChannelRemote", ""))
mockChannelStore.On("Get", invitation.ChannelId, true).Return(nil, &store.ErrNotFound{})
mockTeamStore.On("GetAllPage", 0, 1, mock.Anything).Return([]*model.Team{team}, nil)
mockStore.On("Channel").Return(&mockChannelStore)
mockStore.On("Team").Return(&mockTeamStore)
mockStore.On("SharedChannel").Return(&mockSharedChannelStore)
mockServer = scs.server.(*MockServerIface)
mockServer.On("GetStore").Return(mockStore)
appErr := model.NewAppError("foo", "bar", nil, "boom", http.StatusBadRequest)
mockApp.On("CreateChannelWithUser", mockTypeReqContext, mockTypeChannel, mockTypeString).Return(channel, nil)
mockApp.On("PatchChannelModerationsForChannel", mock.Anything, channel, mock.Anything).Return(nil, appErr)
defer mockApp.AssertExpectations(t)
err = scs.onReceiveChannelInvite(msg, remoteCluster, nil)
require.Error(t, err)
assert.Equal(t, fmt.Sprintf("cannot make channel readonly `%s`: foo: bar, boom", invitation.ChannelId), err.Error())
})
t.Run("When invitation points to a deleted shared channel remote", func(t *testing.T) {
mockServer := &MockServerIface{}
logger := mlog.CreateConsoleTestLogger(t)
mockServer.On("Log").Return(logger)
mockApp := &MockAppIface{}
scs := &Service{
server: mockServer,
app: mockApp,
}
mockStore := &mocks.Store{}
remoteCluster := &model.RemoteCluster{RemoteId: model.NewId(), Name: "test", DefaultTeamId: model.NewId()}
invitation := channelInviteMsg{
ChannelId: model.NewId(),
TeamId: model.NewId(),
Type: model.ChannelTypeOpen,
}
payload, err := json.Marshal(invitation)
require.NoError(t, err)
msg := model.RemoteClusterMsg{
Payload: payload,
}
mockChannelStore := mocks.ChannelStore{}
mockSharedChannelStore := mocks.SharedChannelStore{}
channel := &model.Channel{
Id: invitation.ChannelId,
TeamId: invitation.TeamId,
Type: invitation.Type,
CreatorId: model.NewId(),
}
sharedChannelRemote := &model.SharedChannelRemote{
ChannelId: invitation.ChannelId,
RemoteId: remoteCluster.RemoteId,
DeleteAt: 1234,
}
mockSharedChannelStore.On("GetRemoteByIds", invitation.ChannelId, mock.Anything).Return(sharedChannelRemote, nil)
mockChannelStore.On("Get", invitation.ChannelId, true).Return(channel, nil)
mockSharedChannelStore.On("Save", mock.Anything).Return(nil, nil)
mockSharedChannelStore.On("UpdateRemote", mock.Anything).Return(nil, nil)
mockStore.On("Channel").Return(&mockChannelStore)
mockStore.On("SharedChannel").Return(&mockSharedChannelStore)
mockServer.On("GetStore").Return(mockStore)
setupMockServerWithConfig(mockServer)
mockApp.On("GetSystemBot", mockTypeReqContext).Return(&model.Bot{UserId: model.NewId()}, nil).Once()
mockApp.On("CreatePost", mockTypeReqContext, mock.AnythingOfType("*model.Post"), mockTypeChannel, mock.AnythingOfType("model.CreatePostFlags")).Return(&model.Post{Id: model.NewId()}, false, nil).Once()
defer mockApp.AssertExpectations(t)
err = scs.onReceiveChannelInvite(msg, remoteCluster, nil)
require.NoError(t, err)
})
t.Run("When invitation points to an existing shared channel remote", func(t *testing.T) {
mockServer := &MockServerIface{}
logger := mlog.CreateConsoleTestLogger(t)
mockServer.On("Log").Return(logger)
mockApp := &MockAppIface{}
scs := &Service{
server: mockServer,
app: mockApp,
}
mockStore := &mocks.Store{}
remoteCluster := &model.RemoteCluster{RemoteId: model.NewId(), Name: "test", DefaultTeamId: model.NewId()}
invitation := channelInviteMsg{
ChannelId: model.NewId(),
TeamId: model.NewId(),
Type: model.ChannelTypeOpen,
}
payload, err := json.Marshal(invitation)
require.NoError(t, err)
msg := model.RemoteClusterMsg{
Payload: payload,
}
mockSharedChannelStore := mocks.SharedChannelStore{}
sharedChannelRemote := &model.SharedChannelRemote{
ChannelId: invitation.ChannelId,
RemoteId: remoteCluster.RemoteId,
DeleteAt: 0,
}
mockServer.On("GetStore").Return(mockStore)
mockSharedChannelStore.On("GetRemoteByIds", invitation.ChannelId, remoteCluster.RemoteId).Return(sharedChannelRemote, nil)
mockStore.On("SharedChannel").Return(&mockSharedChannelStore)
defer mockApp.AssertExpectations(t)
err = scs.onReceiveChannelInvite(msg, remoteCluster, nil)
require.NoError(t, err)
})
t.Run("DM channels", func(t *testing.T) {
var testRemoteID = model.NewId()
testCases := []struct {
desc string
user1 *model.User
user2 *model.User
canSee bool
expectSuccess bool
user2InDB bool
user2InParticipants bool
}{
{"valid users", &model.User{Id: model.NewId(), RemoteId: &testRemoteID}, &model.User{Id: model.NewId()}, true, true, true, false},
{"swapped users", &model.User{Id: model.NewId()}, &model.User{Id: model.NewId(), RemoteId: &testRemoteID}, true, true, true, false},
{"two remotes", &model.User{Id: model.NewId(), RemoteId: &testRemoteID}, &model.User{Id: model.NewId(), RemoteId: &testRemoteID}, true, false, true, false},
{"two locals", &model.User{Id: model.NewId()}, &model.User{Id: model.NewId()}, true, false, true, false},
{"can't see", &model.User{Id: model.NewId(), RemoteId: &testRemoteID}, &model.User{Id: model.NewId()}, false, false, true, false},
{"invalid remoteid", &model.User{Id: model.NewId(), RemoteId: new("bogus")}, &model.User{Id: model.NewId()}, true, false, true, false},
{"user2 not in DB but in participants", &model.User{Id: model.NewId(), RemoteId: &testRemoteID}, &model.User{Id: model.NewId()}, true, true, false, true},
{"user2 not in DB and not in participants", &model.User{Id: model.NewId(), RemoteId: &testRemoteID}, &model.User{Id: model.NewId()}, true, false, false, false},
}
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
mockServer := &MockServerIface{}
logger := mlog.CreateConsoleTestLogger(t)
mockServer.On("Log").Return(logger)
mockApp := &MockAppIface{}
scs := &Service{
server: mockServer,
app: mockApp,
}
mockStore := &mocks.Store{}
remoteCluster := &model.RemoteCluster{RemoteId: testRemoteID, Name: "test3", CreatorId: model.NewId()}
invitation := channelInviteMsg{
ChannelId: model.NewId(),
TeamId: model.NewId(),
ReadOnly: false,
Type: model.ChannelTypeDirect,
DirectParticipantIDs: []string{tc.user1.Id, tc.user2.Id},
}
// Add participants to the invitation if needed
if tc.user2InParticipants {
invitation.DirectParticipants = append(invitation.DirectParticipants, tc.user2)
}
payload, err := json.Marshal(invitation)
require.NoError(t, err)
msg := model.RemoteClusterMsg{
Payload: payload,
}
mockChannelStore := mocks.ChannelStore{}
mockSharedChannelStore := mocks.SharedChannelStore{}
channel := &model.Channel{
Id: invitation.ChannelId,
CreatorId: model.NewId(),
}
mockUserStore := mocks.UserStore{}
mockUserStore.On("Get", mockTypeContext, tc.user1.Id).
Return(tc.user1, nil)
if tc.user2InDB {
mockUserStore.On("Get", mockTypeContext, tc.user2.Id).
Return(tc.user2, nil)
} else {
mockUserStore.On("Get", mockTypeContext, tc.user2.Id).
Return(nil, &store.ErrNotFound{})
}
if tc.user2InParticipants {
mockUserStore.On("Save", mock.AnythingOfType("*request.Context"),
mock.MatchedBy(func(u *model.User) bool {
return u.Id == tc.user2.Id
})).Return(tc.user2, nil)
}
mockChannelStore.On("Get", invitation.ChannelId, true).Return(nil, errors.New("boom"))
mockChannelStore.On("GetByName", "", mockTypeString, true).Return(nil, &store.ErrNotFound{})
mockSharedChannelStore.On("GetRemoteByIds", invitation.ChannelId, remoteCluster.RemoteId).Return(nil, store.NewErrNotFound("SharedChannelRemote", ""))
mockSharedChannelStore.On("Save", mock.Anything).Return(nil, nil)
mockSharedChannelStore.On("SaveRemote", mock.Anything).Return(nil, nil)
mockStore.On("Channel").Return(&mockChannelStore)
mockStore.On("SharedChannel").Return(&mockSharedChannelStore)
mockStore.On("User").Return(&mockUserStore)
mockServer = scs.server.(*MockServerIface)
mockServer.On("GetStore").Return(mockStore)
setupMockServerWithConfig(mockServer)
mockApp.On("GetOrCreateDirectChannel", mockTypeReqContext, mockTypeString, mockTypeString, mock.AnythingOfType("model.ChannelOption")).
Return(channel, nil).Maybe()
mockApp.On("UserCanSeeOtherUser", mockTypeReqContext, mockTypeString, mockTypeString).Return(tc.canSee, nil).Maybe()
mockApp.On("NotifySharedChannelUserUpdate", mockTypeUser).Return().Maybe()
if tc.expectSuccess {
mockApp.On("GetSystemBot", mockTypeReqContext).Return(&model.Bot{UserId: model.NewId()}, nil).Once()
mockApp.On("CreatePost", mockTypeReqContext, mock.AnythingOfType("*model.Post"), mockTypeChannel, mock.AnythingOfType("model.CreatePostFlags")).Return(&model.Post{Id: model.NewId()}, false, nil).Once()
}
defer mockApp.AssertExpectations(t)
err = scs.onReceiveChannelInvite(msg, remoteCluster, nil)
require.Equal(t, tc.expectSuccess, err == nil)
})
}
})
}
func TestSendChannelInvite_ExistingSharedConnection(t *testing.T) {
channelID := model.NewId()
userID := model.NewId()
remoteID := model.NewId()
sharedChannel := &model.SharedChannel{ChannelId: channelID}
channel := &model.Channel{Id: channelID}
// Remote with LastPingAt 0 is offline (IsOnline() returns false)
rc := &model.RemoteCluster{RemoteId: remoteID, Name: "test-remote", DisplayName: "Test Remote", LastPingAt: 0}
t.Run("when remote is offline and existing connection is not soft deleted, returns ErrChannelAlreadyShared", func(t *testing.T) {
mockServer := &MockServerIface{}
logger := mlog.CreateConsoleTestLogger(t)
mockServer.On("Log").Return(logger)
mockApp := &MockAppIface{}
scs := &Service{server: mockServer, app: mockApp}
mockStore := &mocks.Store{}
mockSharedChannelStore := mocks.SharedChannelStore{}
existingScr := &model.SharedChannelRemote{
ChannelId: channelID,
RemoteId: remoteID,
DeleteAt: 0, // not soft deleted -> already connected
}
mockSharedChannelStore.On("Get", channelID).Return(sharedChannel, nil)
mockSharedChannelStore.On("GetRemoteByIds", channelID, remoteID).Return(existingScr, nil)
mockStore.On("SharedChannel").Return(&mockSharedChannelStore)
mockServer.On("GetStore").Return(mockStore)
mockServer.On("GetRemoteClusterService").Return(&stubRemoteClusterService{})
mockApp.On("SendEphemeralPost", mockTypeReqContext, userID, mock.MatchedBy(func(post *model.Post) bool {
return post != nil && post.ChannelId == channelID && post.Message != ""
})).Return(nil, true).Once()
err := scs.SendChannelInvite(channel, userID, rc)
require.Error(t, err)
assert.ErrorIs(t, err, model.ErrChannelAlreadyShared)
mockApp.AssertExpectations(t)
})
t.Run("when remote is offline and existing connection is soft deleted, restores and succeeds", func(t *testing.T) {
mockServer := &MockServerIface{}
logger := mlog.CreateConsoleTestLogger(t)
mockServer.On("Log").Return(logger)
mockApp := &MockAppIface{}
scs := &Service{server: mockServer, app: mockApp}
mockStore := &mocks.Store{}
mockSharedChannelStore := mocks.SharedChannelStore{}
existingScr := &model.SharedChannelRemote{
ChannelId: channelID,
RemoteId: remoteID,
DeleteAt: 12345, // soft deleted
}
mockSharedChannelStore.On("Get", channelID).Return(sharedChannel, nil)
mockSharedChannelStore.On("GetRemoteByIds", channelID, remoteID).Return(existingScr, nil)
mockSharedChannelStore.On("UpdateRemote", mock.MatchedBy(func(scr *model.SharedChannelRemote) bool {
return scr.ChannelId == channelID && scr.RemoteId == remoteID && scr.DeleteAt == 0 && scr.CreatorId == userID &&
scr.IsInviteAccepted && !scr.IsInviteConfirmed && scr.LastMembersSyncAt == 0
})).Return(nil, nil).Once()
mockStore.On("SharedChannel").Return(&mockSharedChannelStore)
mockServer.On("GetStore").Return(mockStore)
mockServer.On("GetRemoteClusterService").Return(&stubRemoteClusterService{})
err := scs.SendChannelInvite(channel, userID, rc)
require.NoError(t, err)
mockSharedChannelStore.AssertExpectations(t)
})
t.Run("when remote is offline and no existing connection, saves new and succeeds", func(t *testing.T) {
mockServer := &MockServerIface{}
logger := mlog.CreateConsoleTestLogger(t)
mockServer.On("Log").Return(logger)
mockApp := &MockAppIface{}
scs := &Service{server: mockServer, app: mockApp}
mockStore := &mocks.Store{}
mockSharedChannelStore := mocks.SharedChannelStore{}
mockSharedChannelStore.On("Get", channelID).Return(sharedChannel, nil)
mockSharedChannelStore.On("GetRemoteByIds", channelID, remoteID).Return(nil, store.NewErrNotFound("SharedChannelRemote", ""))
mockSharedChannelStore.On("SaveRemote", mock.MatchedBy(func(scr *model.SharedChannelRemote) bool {
return scr.ChannelId == channelID && scr.RemoteId == remoteID && scr.CreatorId == userID &&
scr.IsInviteAccepted && !scr.IsInviteConfirmed
})).Return(nil, nil).Once()
mockStore.On("SharedChannel").Return(&mockSharedChannelStore)
mockServer.On("GetStore").Return(mockStore)
mockServer.On("GetRemoteClusterService").Return(&stubRemoteClusterService{})
err := scs.SendChannelInvite(channel, userID, rc)
require.NoError(t, err)
mockSharedChannelStore.AssertExpectations(t)
})
}

View File

@@ -0,0 +1,57 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sharedchannel
import (
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (scs *Service) isChannelMemberSyncEnabled() bool {
return scs.server.GetRemoteClusterService() != nil
}
// NotifyMembershipChanged is called when users are added or removed from a shared channel.
// It triggers a sync for the channel so that membership changes are picked up from
// ChannelMemberHistory at sync time, following the same pattern as posts and reactions.
// originRemoteID identifies the remote that initiated the change, so it can be skipped
// during sync to prevent echo-back.
func (scs *Service) NotifyMembershipChanged(channelID string, originRemoteID string) {
if !scs.isChannelMemberSyncEnabled() {
return
}
task := newSyncTask(channelID, "", "", nil, nil)
task.originRemoteID = originRemoteID
task.schedule = time.Now().Add(NotifyMinimumDelay)
scs.addTask(task)
}
// ForceMembershipSyncForRemote triggers a sync for all channels shared with the specified remote.
// Called when a remote comes back online to catch up on any membership changes that occurred
// while it was offline. The sync pipeline will read ChannelMemberHistory using the
// LastMembersSyncAt cursor to detect both additions and removals.
func (scs *Service) ForceMembershipSyncForRemote(rc *model.RemoteCluster) {
if !scs.isChannelMemberSyncEnabled() {
return
}
opts := model.SharedChannelRemoteFilterOpts{
RemoteId: rc.RemoteId,
}
scrs, err := scs.server.GetStore().SharedChannel().GetRemotes(0, 999999, opts)
if err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "Failed to fetch shared channel remotes for membership sync",
mlog.String("remote", rc.DisplayName),
mlog.String("remote_id", rc.RemoteId),
mlog.Err(err),
)
return
}
for _, scr := range scrs {
scs.NotifyChannelChanged(scr.ChannelId)
}
}

View File

@@ -0,0 +1,193 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sharedchannel
import (
"fmt"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
)
const (
// Error IDs returned by the app layer that indicate an idempotent no-op.
errIDAddUserToChannelFailed = "api.channel.add_user.to.channel.failed.app_error"
errIDSaveMemberExists = "app.channel.save_member.exists.app_error"
errIDGetChannelMemberMissing = "app.channel.get_member.missing.app_error"
)
// onReceiveMembershipChanges processes channel membership changes from a remote cluster.
// In the new model, the sender derives the authoritative net state from ChannelMemberHistory.
// Both processMemberAdd and processMemberRemove are idempotent:
// - processMemberAdd ignores "already added" errors
// - processMemberRemove ignores "not found" errors
//
// Out-of-order messages resolve naturally: if an old "add" arrives after a newer "remove",
// the sender's next sync cycle will send a corrective "remove" because the history shows the user left.
func (scs *Service) onReceiveMembershipChanges(syncMsg *model.SyncMsg, rc *model.RemoteCluster) error {
if len(syncMsg.MembershipChanges) == 0 {
return fmt.Errorf("onReceiveMembershipChanges: no membership changes")
}
// Get the channel to make sure it exists and is shared
channel, err := scs.server.GetStore().Channel().Get(syncMsg.ChannelId, true)
if err != nil {
return fmt.Errorf("cannot get channel for membership changes: %w", err)
}
// Verify this is a valid shared channel
_, err = scs.server.GetStore().SharedChannel().Get(syncMsg.ChannelId)
if err != nil {
return fmt.Errorf("cannot get shared channel for membership changes: %w", err)
}
// Process each change
var failCount int
for _, change := range syncMsg.MembershipChanges {
if change.ChannelId != syncMsg.ChannelId {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "ChannelId mismatch in membership change",
mlog.String("expected", syncMsg.ChannelId),
mlog.String("got", change.ChannelId),
mlog.String("remote_id", rc.RemoteId),
)
failCount++
continue
}
// Process the membership change based on whether it's an add or remove
var processErr error
if change.IsAdd {
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Adding user to channel from remote cluster",
mlog.String("user_id", change.UserId),
mlog.String("channel_id", change.ChannelId),
mlog.String("remote_id", rc.RemoteId),
)
processErr = scs.processMemberAdd(change, channel, rc, syncMsg)
} else {
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Removing user from channel from remote cluster",
mlog.String("user_id", change.UserId),
mlog.String("channel_id", change.ChannelId),
mlog.String("remote_id", rc.RemoteId),
)
processErr = scs.processMemberRemove(change, rc)
}
if processErr != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "Failed to process membership change",
mlog.String("user_id", change.UserId),
mlog.String("channel_id", change.ChannelId),
mlog.String("remote_id", rc.RemoteId),
mlog.Bool("is_add", change.IsAdd),
mlog.Err(processErr),
)
failCount++
continue
}
}
if failCount > 0 {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "Some membership changes failed",
mlog.String("channel_id", syncMsg.ChannelId),
mlog.Int("total", len(syncMsg.MembershipChanges)),
mlog.Int("failed", failCount),
)
}
return nil
}
// processMemberAdd handles adding a user to a channel
func (scs *Service) processMemberAdd(change *model.MembershipChangeMsg, channel *model.Channel, rc *model.RemoteCluster, syncMsg *model.SyncMsg) error {
rctx := request.EmptyContext(scs.server.Log())
var user *model.User
var err error
// First try to upsert user from sync message (mirrors mention scenario)
if userProfile, exists := syncMsg.Users[change.UserId]; exists {
user, err = scs.upsertSyncUser(rctx, userProfile, channel, rc)
if err != nil {
return fmt.Errorf("cannot upsert user for channel add: %w", err)
}
} else {
// Fallback to existing lookup for users not in sync message
user, err = scs.server.GetStore().User().Get(rctx, change.UserId)
if err != nil {
return fmt.Errorf("cannot get user for channel add: %w", err)
}
}
// Only process users that originated from the sending remote.
// Local users (RemoteID == "") are managed locally, and users from other
// remotes should not be manipulated by this sender.
remoteID := user.GetRemoteID()
if remoteID == "" {
return nil
}
if remoteID != rc.RemoteId {
return fmt.Errorf("membership add sync failed: %w", ErrRemoteIDMismatch)
}
// Check user permissions for private channels
if channel.Type == model.ChannelTypePrivate {
// Add user to team if needed for private channel
if appErr := scs.app.AddUserToTeamByTeamId(rctx, channel.TeamId, user); appErr != nil {
return fmt.Errorf("cannot add user to team for private channel: %w", appErr)
}
}
// Use the app layer to add the user to the channel
// Skip team member check (true) since we already handled team membership above
_, appErr := scs.app.AddUserToChannel(rctx, user, channel, true)
if appErr != nil {
// Skip "already added" errors — idempotent
if appErr.Id != errIDAddUserToChannelFailed &&
appErr.Id != errIDSaveMemberExists {
return fmt.Errorf("cannot add user to channel: %w", appErr)
}
}
return nil
}
// processMemberRemove handles removing a user from a channel
func (scs *Service) processMemberRemove(change *model.MembershipChangeMsg, rc *model.RemoteCluster) error {
// Get channel so we can use app layer methods properly
channel, err := scs.server.GetStore().Channel().Get(change.ChannelId, true)
if err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "Cannot find channel for member removal",
mlog.String("channel_id", change.ChannelId),
mlog.String("user_id", change.UserId),
mlog.Err(err),
)
return nil // channel might be deleted, nothing to do
}
rctx := request.EmptyContext(scs.server.Log())
user, userErr := scs.server.GetStore().User().Get(rctx, change.UserId)
if userErr != nil {
return fmt.Errorf("cannot get user for channel remove: %w", userErr)
}
remoteID := user.GetRemoteID()
if remoteID == "" {
return nil
}
if remoteID != rc.RemoteId {
return fmt.Errorf("membership remove sync failed: %w", ErrRemoteIDMismatch)
}
// Use the app layer's remove user method
appErr := scs.app.RemoveUserFromChannel(rctx, change.UserId, "", channel)
if appErr != nil {
// Ignore "not found" errors - the user might already be removed
if appErr.Id == errIDGetChannelMemberMissing {
return nil
}
return fmt.Errorf("cannot remove user from channel: %w", appErr)
}
return nil
}

View File

@@ -0,0 +1,397 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sharedchannel
import (
"net/http"
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin/plugintest/mock"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
)
// setupMembershipTest creates the common test fixtures for membership sync tests.
// Returns a Service, the mock stores, and IDs for the channel, remote, and team.
func setupMembershipTest(t *testing.T) (*Service, *MockServerIface, *MockAppIface, *mocks.Store, *mocks.SharedChannelStore, *mocks.ChannelStore, *mocks.UserStore) {
t.Helper()
mockServer := &MockServerIface{}
logger := mlog.CreateConsoleTestLogger(t)
mockServer.On("Log").Return(logger)
mockApp := &MockAppIface{}
scs := &Service{
server: mockServer,
app: mockApp,
}
mockStore := &mocks.Store{}
mockSharedChannelStore := &mocks.SharedChannelStore{}
mockChannelStore := &mocks.ChannelStore{}
mockUserStore := &mocks.UserStore{}
mockStore.On("SharedChannel").Return(mockSharedChannelStore)
mockStore.On("Channel").Return(mockChannelStore)
mockStore.On("User").Return(mockUserStore)
mockServer.On("GetStore").Return(mockStore)
mockConfig := model.Config{}
mockConfig.SetDefaults()
mockServer.On("Config").Return(&mockConfig)
return scs, mockServer, mockApp, mockStore, mockSharedChannelStore, mockChannelStore, mockUserStore
}
func TestOnReceiveMembershipChanges_ChannelIdMismatch(t *testing.T) {
scs, _, _, _, mockSharedChannelStore, mockChannelStore, _ := setupMembershipTest(t)
channelId := model.NewId()
remoteId := model.NewId()
rc := &model.RemoteCluster{RemoteId: remoteId}
channel := &model.Channel{Id: channelId, Type: model.ChannelTypeOpen}
mockChannelStore.On("Get", channelId, true).Return(channel, nil)
mockSharedChannelStore.On("Get", channelId).Return(&model.SharedChannel{ChannelId: channelId}, nil)
syncMsg := &model.SyncMsg{
ChannelId: channelId,
MembershipChanges: []*model.MembershipChangeMsg{
{
ChannelId: model.NewId(), // different channel ID
UserId: model.NewId(),
IsAdd: true,
ChangeTime: 1000,
},
},
}
err := scs.onReceiveMembershipChanges(syncMsg, rc)
require.NoError(t, err) // function returns nil even on individual failures
}
func TestProcessMemberAdd_RejectsLocalUser(t *testing.T) {
scs, _, mockApp, _, mockSharedChannelStore, mockChannelStore, mockUserStore := setupMembershipTest(t)
channelId := model.NewId()
remoteId := model.NewId()
localUserId := model.NewId()
rc := &model.RemoteCluster{RemoteId: remoteId}
channel := &model.Channel{Id: channelId, Type: model.ChannelTypeOpen}
mockChannelStore.On("Get", channelId, true).Return(channel, nil)
mockSharedChannelStore.On("Get", channelId).Return(&model.SharedChannel{ChannelId: channelId}, nil)
// Local user has no remote ID
localUser := &model.User{Id: localUserId}
mockUserStore.On("Get", mockTypeContext, localUserId).Return(localUser, nil)
syncMsg := &model.SyncMsg{
ChannelId: channelId,
MembershipChanges: []*model.MembershipChangeMsg{
{
ChannelId: channelId,
UserId: localUserId,
IsAdd: true,
ChangeTime: 1000,
},
},
}
err := scs.onReceiveMembershipChanges(syncMsg, rc)
require.NoError(t, err) // function returns nil even on individual failures
mockApp.AssertNotCalled(t, "AddUserToChannel", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
}
func TestProcessMemberAdd_RejectsOtherRemoteUser(t *testing.T) {
scs, _, mockApp, _, mockSharedChannelStore, mockChannelStore, mockUserStore := setupMembershipTest(t)
channelId := model.NewId()
remoteId := model.NewId()
otherRemoteId := model.NewId()
userId := model.NewId()
rc := &model.RemoteCluster{RemoteId: remoteId}
channel := &model.Channel{Id: channelId, Type: model.ChannelTypeOpen}
mockChannelStore.On("Get", channelId, true).Return(channel, nil)
mockSharedChannelStore.On("Get", channelId).Return(&model.SharedChannel{ChannelId: channelId}, nil)
// User belongs to a different remote
otherRemoteUser := &model.User{Id: userId, RemoteId: &otherRemoteId}
mockUserStore.On("Get", mockTypeContext, userId).Return(otherRemoteUser, nil)
syncMsg := &model.SyncMsg{
ChannelId: channelId,
MembershipChanges: []*model.MembershipChangeMsg{
{
ChannelId: channelId,
UserId: userId,
IsAdd: true,
ChangeTime: 1000,
},
},
}
err := scs.onReceiveMembershipChanges(syncMsg, rc)
require.NoError(t, err)
mockApp.AssertNotCalled(t, "AddUserToChannel", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
}
func TestProcessMemberAdd_AllowsOwnRemoteUser(t *testing.T) {
scs, _, mockApp, _, mockSharedChannelStore, mockChannelStore, mockUserStore := setupMembershipTest(t)
channelId := model.NewId()
remoteId := model.NewId()
userId := model.NewId()
rc := &model.RemoteCluster{RemoteId: remoteId}
channel := &model.Channel{Id: channelId, Type: model.ChannelTypeOpen}
mockChannelStore.On("Get", channelId, true).Return(channel, nil)
mockSharedChannelStore.On("Get", channelId).Return(&model.SharedChannel{ChannelId: channelId}, nil)
// User belongs to the sending remote
remoteUser := &model.User{Id: userId, RemoteId: &remoteId}
mockUserStore.On("Get", mockTypeContext, userId).Return(remoteUser, nil)
// Expect the add to proceed
mockApp.On("AddUserToChannel", mockTypeReqContext, mockTypeUser, mockTypeChannel, true).Return(&model.ChannelMember{}, nil)
syncMsg := &model.SyncMsg{
ChannelId: channelId,
MembershipChanges: []*model.MembershipChangeMsg{
{
ChannelId: channelId,
UserId: userId,
IsAdd: true,
ChangeTime: 1000,
},
},
}
err := scs.onReceiveMembershipChanges(syncMsg, rc)
require.NoError(t, err)
mockApp.AssertCalled(t, "AddUserToChannel", mockTypeReqContext, mockTypeUser, mockTypeChannel, true)
}
func TestProcessMemberRemove_RejectsLocalUser(t *testing.T) {
scs, _, mockApp, _, mockSharedChannelStore, mockChannelStore, mockUserStore := setupMembershipTest(t)
channelId := model.NewId()
remoteId := model.NewId()
localUserId := model.NewId()
rc := &model.RemoteCluster{RemoteId: remoteId}
channel := &model.Channel{Id: channelId, Type: model.ChannelTypeOpen}
mockChannelStore.On("Get", channelId, true).Return(channel, nil)
mockSharedChannelStore.On("Get", channelId).Return(&model.SharedChannel{ChannelId: channelId}, nil)
// Local user has no remote ID
localUser := &model.User{Id: localUserId}
mockUserStore.On("Get", mockTypeContext, localUserId).Return(localUser, nil)
syncMsg := &model.SyncMsg{
ChannelId: channelId,
MembershipChanges: []*model.MembershipChangeMsg{
{
ChannelId: channelId,
UserId: localUserId,
IsAdd: false,
ChangeTime: 1000,
},
},
}
err := scs.onReceiveMembershipChanges(syncMsg, rc)
require.NoError(t, err)
mockApp.AssertNotCalled(t, "RemoveUserFromChannel", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
}
func TestProcessMemberRemove_RejectsOtherRemoteUser(t *testing.T) {
scs, _, mockApp, _, mockSharedChannelStore, mockChannelStore, mockUserStore := setupMembershipTest(t)
channelId := model.NewId()
remoteId := model.NewId()
otherRemoteId := model.NewId()
userId := model.NewId()
rc := &model.RemoteCluster{RemoteId: remoteId}
channel := &model.Channel{Id: channelId, Type: model.ChannelTypeOpen}
mockChannelStore.On("Get", channelId, true).Return(channel, nil)
mockSharedChannelStore.On("Get", channelId).Return(&model.SharedChannel{ChannelId: channelId}, nil)
// User belongs to a different remote
otherRemoteUser := &model.User{Id: userId, RemoteId: &otherRemoteId}
mockUserStore.On("Get", mockTypeContext, userId).Return(otherRemoteUser, nil)
syncMsg := &model.SyncMsg{
ChannelId: channelId,
MembershipChanges: []*model.MembershipChangeMsg{
{
ChannelId: channelId,
UserId: userId,
IsAdd: false,
ChangeTime: 1000,
},
},
}
err := scs.onReceiveMembershipChanges(syncMsg, rc)
require.NoError(t, err)
mockApp.AssertNotCalled(t, "RemoveUserFromChannel", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
}
func TestProcessMemberRemove_AllowsOwnRemoteUser(t *testing.T) {
scs, _, mockApp, _, mockSharedChannelStore, mockChannelStore, mockUserStore := setupMembershipTest(t)
channelId := model.NewId()
remoteId := model.NewId()
userId := model.NewId()
rc := &model.RemoteCluster{RemoteId: remoteId}
channel := &model.Channel{Id: channelId, Type: model.ChannelTypeOpen}
mockChannelStore.On("Get", channelId, true).Return(channel, nil)
mockSharedChannelStore.On("Get", channelId).Return(&model.SharedChannel{ChannelId: channelId}, nil)
// User belongs to the sending remote
remoteUser := &model.User{Id: userId, RemoteId: &remoteId}
mockUserStore.On("Get", mockTypeContext, userId).Return(remoteUser, nil)
// Expect the remove to proceed
mockApp.On("RemoveUserFromChannel", mockTypeReqContext, userId, "", channel).Return(nil)
syncMsg := &model.SyncMsg{
ChannelId: channelId,
MembershipChanges: []*model.MembershipChangeMsg{
{
ChannelId: channelId,
UserId: userId,
IsAdd: false,
ChangeTime: 1000,
},
},
}
err := scs.onReceiveMembershipChanges(syncMsg, rc)
require.NoError(t, err)
mockApp.AssertCalled(t, "RemoveUserFromChannel", mockTypeReqContext, userId, "", channel)
}
func TestProcessMemberAdd_SkipsLocalUser(t *testing.T) {
scs, _, _, _, _, _, mockUserStore := setupMembershipTest(t)
channelId := model.NewId()
remoteId := model.NewId()
localUserId := model.NewId()
rc := &model.RemoteCluster{RemoteId: remoteId}
channel := &model.Channel{Id: channelId, Type: model.ChannelTypeOpen}
// Local user (no remote ID) — needed for the fallback User().Get path
mockUserStore.On("Get", mockTypeContext, localUserId).Return(&model.User{Id: localUserId}, nil)
change := &model.MembershipChangeMsg{
ChannelId: channelId,
UserId: localUserId,
IsAdd: true,
ChangeTime: 1000,
}
syncMsg := &model.SyncMsg{
ChannelId: channelId,
}
err := scs.processMemberAdd(change, channel, rc, syncMsg)
require.NoError(t, err, "local users should be silently skipped")
}
func TestProcessMemberRemove_SkipsLocalUser(t *testing.T) {
scs, _, _, _, _, mockChannelStore, mockUserStore := setupMembershipTest(t)
channelId := model.NewId()
remoteId := model.NewId()
localUserId := model.NewId()
rc := &model.RemoteCluster{RemoteId: remoteId}
channel := &model.Channel{Id: channelId, Type: model.ChannelTypeOpen}
mockChannelStore.On("Get", channelId, true).Return(channel, nil)
// Local user (no remote ID)
mockUserStore.On("Get", mockTypeContext, localUserId).Return(&model.User{Id: localUserId}, nil)
change := &model.MembershipChangeMsg{
ChannelId: channelId,
UserId: localUserId,
IsAdd: false,
ChangeTime: 1000,
}
err := scs.processMemberRemove(change, rc)
require.NoError(t, err, "local users should be silently skipped")
}
func TestProcessMemberAdd_IdempotentForExistingMember(t *testing.T) {
scs, _, mockApp, _, _, _, mockUserStore := setupMembershipTest(t)
channelId := model.NewId()
remoteId := model.NewId()
userId := model.NewId()
rc := &model.RemoteCluster{RemoteId: remoteId}
channel := &model.Channel{Id: channelId, Type: model.ChannelTypeOpen}
// User belongs to the sending remote
remoteUser := &model.User{Id: userId, RemoteId: &remoteId}
mockUserStore.On("Get", mockTypeContext, userId).Return(remoteUser, nil)
// Simulate "already added" error from AddUserToChannel
alreadyAddedErr := model.NewAppError("AddUserToChannel", errIDSaveMemberExists, nil, "", http.StatusBadRequest)
mockApp.On("AddUserToChannel", mockTypeReqContext, mockTypeUser, mockTypeChannel, true).Return(nil, alreadyAddedErr)
syncMsg := &model.SyncMsg{ChannelId: channelId}
change := &model.MembershipChangeMsg{
ChannelId: channelId,
UserId: userId,
IsAdd: true,
ChangeTime: 1000,
}
err := scs.processMemberAdd(change, channel, rc, syncMsg)
require.NoError(t, err, "should succeed for already-added user (idempotent)")
}
func TestProcessMemberRemove_IdempotentForNonMember(t *testing.T) {
scs, _, mockApp, _, _, mockChannelStore, mockUserStore := setupMembershipTest(t)
channelId := model.NewId()
remoteId := model.NewId()
userId := model.NewId()
rc := &model.RemoteCluster{RemoteId: remoteId}
channel := &model.Channel{Id: channelId, Type: model.ChannelTypeOpen}
mockChannelStore.On("Get", channelId, true).Return(channel, nil)
// User belongs to the sending remote
remoteUser := &model.User{Id: userId, RemoteId: &remoteId}
mockUserStore.On("Get", mockTypeContext, userId).Return(remoteUser, nil)
// Simulate "not found" error from RemoveUserFromChannel
notFoundErr := model.NewAppError("RemoveUserFromChannel", errIDGetChannelMemberMissing, nil, "", http.StatusNotFound)
mockApp.On("RemoveUserFromChannel", mockTypeReqContext, userId, "", channel).Return(notFoundErr)
change := &model.MembershipChangeMsg{
ChannelId: channelId,
UserId: userId,
IsAdd: false,
ChangeTime: 1000,
}
err := scs.processMemberRemove(change, rc)
require.NoError(t, err, "should succeed for already-removed user (idempotent)")
}

View File

@@ -0,0 +1,391 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sharedchannel
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin/plugintest/mock"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
)
func setupSendTest(t *testing.T) (*Service, *mocks.Store, *mocks.ChannelMemberHistoryStore, *mocks.SharedChannelStore, *mocks.UserStore, *mocks.RemoteClusterStore) {
t.Helper()
mockServer := &MockServerIface{}
logger := mlog.CreateConsoleTestLogger(t)
mockServer.On("Log").Return(logger)
mockServer.On("GetMetrics").Return(nil)
mockServer.On("GetRemoteClusterService").Return(nil)
mockApp := &MockAppIface{}
scs := &Service{
server: mockServer,
app: mockApp,
}
mockStore := &mocks.Store{}
mockCMHStore := &mocks.ChannelMemberHistoryStore{}
mockSharedChannelStore := &mocks.SharedChannelStore{}
mockUserStore := &mocks.UserStore{}
mockRCStore := &mocks.RemoteClusterStore{}
mockStore.On("ChannelMemberHistory").Return(mockCMHStore)
mockStore.On("SharedChannel").Return(mockSharedChannelStore)
mockStore.On("User").Return(mockUserStore)
mockStore.On("RemoteCluster").Return(mockRCStore)
mockServer.On("GetStore").Return(mockStore)
mockConfig := model.Config{}
mockConfig.SetDefaults()
mockServer.On("Config").Return(&mockConfig)
return scs, mockStore, mockCMHStore, mockSharedChannelStore, mockUserStore, mockRCStore
}
func TestFetchMembershipsForSync_NoChanges(t *testing.T) {
scs, _, mockCMHStore, _, _, _ := setupSendTest(t)
channelID := model.NewId()
mockCMHStore.On("GetMembershipChanges", channelID, int64(0), mock.AnythingOfType("int")).
Return([]*model.ChannelMemberHistory{}, nil)
sd := &syncData{
task: syncTask{channelID: channelID},
rc: &model.RemoteCluster{RemoteId: model.NewId()},
scr: &model.SharedChannelRemote{LastMembersSyncAt: 0},
users: make(map[string]*model.User),
}
err := scs.fetchMembershipsForSync(sd)
require.NoError(t, err)
assert.Empty(t, sd.membershipChanges)
assert.Equal(t, int64(0), sd.resultNextMembershipCursor)
}
func TestFetchMembershipsForSync_DeduplicatesJoinLeaveRejoin(t *testing.T) {
scs, _, mockCMHStore, mockSCStore, mockUserStore, _ := setupSendTest(t)
channelID := model.NewId()
remoteID := model.NewId()
userID := model.NewId()
// User joined at 1000, left at 2000, rejoined at 3000
mockCMHStore.On("GetMembershipChanges", channelID, int64(0), mock.AnythingOfType("int")).
Return([]*model.ChannelMemberHistory{
{ChannelId: channelID, UserId: userID, JoinTime: 1000, LeaveTime: new(int64(2000))},
{ChannelId: channelID, UserId: userID, JoinTime: 3000, LeaveTime: nil},
}, nil)
// User profile for the add — local user (no RemoteId) so shouldUserSync includes it
user := &model.User{Id: userID}
mockUserStore.On("Get", mock.Anything, userID).Return(user, nil)
// shouldUserSync needs SharedChannelUser lookup
mockSCStore.On("GetSingleUser", userID, channelID, remoteID).
Return(nil, &notFoundError{})
mockSCStore.On("SaveUser", mock.AnythingOfType("*model.SharedChannelUser")).
Return(&model.SharedChannelUser{}, nil)
sd := &syncData{
task: syncTask{channelID: channelID},
rc: &model.RemoteCluster{RemoteId: remoteID},
scr: &model.SharedChannelRemote{LastMembersSyncAt: 0},
users: make(map[string]*model.User),
}
err := scs.fetchMembershipsForSync(sd)
require.NoError(t, err)
// Should deduplicate to 1 entry: user is currently a member (rejoin at 3000 > leave at 2000)
require.Len(t, sd.membershipChanges, 1)
assert.Equal(t, userID, sd.membershipChanges[0].UserId)
assert.True(t, sd.membershipChanges[0].IsAdd, "user rejoined so should be IsAdd=true")
assert.Equal(t, int64(3000), sd.membershipChanges[0].ChangeTime)
assert.Equal(t, int64(3000), sd.resultNextMembershipCursor)
}
func TestFetchMembershipsForSync_DeduplicatesJoinThenLeave(t *testing.T) {
scs, _, mockCMHStore, _, mockUserStore, _ := setupSendTest(t)
channelID := model.NewId()
remoteID := model.NewId()
userID := model.NewId()
// User joined at 1000, then left at 2000
mockCMHStore.On("GetMembershipChanges", channelID, int64(0), mock.AnythingOfType("int")).
Return([]*model.ChannelMemberHistory{
{ChannelId: channelID, UserId: userID, JoinTime: 1000, LeaveTime: new(int64(2000))},
}, nil)
// User profile needed for remote-origin filtering
mockUserStore.On("Get", mock.Anything, userID).Return(&model.User{Id: userID}, nil)
sd := &syncData{
task: syncTask{channelID: channelID},
rc: &model.RemoteCluster{RemoteId: remoteID},
scr: &model.SharedChannelRemote{LastMembersSyncAt: 0},
users: make(map[string]*model.User),
}
err := scs.fetchMembershipsForSync(sd)
require.NoError(t, err)
require.Len(t, sd.membershipChanges, 1)
assert.False(t, sd.membershipChanges[0].IsAdd, "user left so should be IsAdd=false")
assert.Equal(t, int64(2000), sd.membershipChanges[0].ChangeTime)
assert.Equal(t, int64(2000), sd.resultNextMembershipCursor)
}
func TestFetchMembershipsForSync_MultipleUsers(t *testing.T) {
scs, _, mockCMHStore, mockSCStore, mockUserStore, _ := setupSendTest(t)
channelID := model.NewId()
remoteID := model.NewId()
user1 := model.NewId()
user2 := model.NewId()
user3 := model.NewId()
mockCMHStore.On("GetMembershipChanges", channelID, int64(0), mock.AnythingOfType("int")).
Return([]*model.ChannelMemberHistory{
{ChannelId: channelID, UserId: user1, JoinTime: 1000, LeaveTime: nil}, // joined, still member
{ChannelId: channelID, UserId: user2, JoinTime: 2000, LeaveTime: new(int64(3000))}, // joined then left
{ChannelId: channelID, UserId: user3, JoinTime: 4000, LeaveTime: nil}, // joined, still member
}, nil)
// User profiles for all users — needed for remote-origin filtering
for _, uid := range []string{user1, user2, user3} {
u := &model.User{Id: uid}
mockUserStore.On("Get", mock.Anything, uid).Return(u, nil)
}
// shouldUserSync mocks for adds (user1 and user3) — users are local (no RemoteId)
// so shouldUserSync won't filter them out for the target remote
for _, uid := range []string{user1, user3} {
mockSCStore.On("GetSingleUser", uid, channelID, remoteID).Return(nil, &notFoundError{})
mockSCStore.On("SaveUser", mock.MatchedBy(func(scu *model.SharedChannelUser) bool { return scu.UserId == uid })).
Return(&model.SharedChannelUser{}, nil)
}
sd := &syncData{
task: syncTask{channelID: channelID},
rc: &model.RemoteCluster{RemoteId: remoteID},
scr: &model.SharedChannelRemote{LastMembersSyncAt: 0},
users: make(map[string]*model.User),
}
err := scs.fetchMembershipsForSync(sd)
require.NoError(t, err)
assert.Len(t, sd.membershipChanges, 3)
// Build a map for easy lookup
changeMap := make(map[string]*model.MembershipChangeMsg)
for _, mc := range sd.membershipChanges {
changeMap[mc.UserId] = mc
}
assert.True(t, changeMap[user1].IsAdd, "user1 is still a member")
assert.False(t, changeMap[user2].IsAdd, "user2 left")
assert.True(t, changeMap[user3].IsAdd, "user3 is still a member")
// Max cursor should be 4000 (user3 join)
assert.Equal(t, int64(4000), sd.resultNextMembershipCursor)
// Only user1 and user3 should be in the users map (adds only)
assert.Contains(t, sd.users, user1)
assert.NotContains(t, sd.users, user2, "user2 left, should not be in users map")
assert.Contains(t, sd.users, user3)
}
func TestFetchMembershipsForSync_SetsRepeatWhenLimitHit(t *testing.T) {
scs, _, mockCMHStore, mockSCStore, mockUserStore, _ := setupSendTest(t)
channelID := model.NewId()
remoteID := model.NewId()
// Return exactly as many results as the limit (batch size)
batchSize := scs.GetMemberSyncBatchSize()
histories := make([]*model.ChannelMemberHistory, batchSize)
for i := range batchSize {
uid := model.NewId()
histories[i] = &model.ChannelMemberHistory{
ChannelId: channelID,
UserId: uid,
JoinTime: int64(1000 + i),
LeaveTime: nil,
}
u := &model.User{Id: uid}
mockUserStore.On("Get", mock.Anything, uid).Return(u, nil)
mockSCStore.On("GetSingleUser", uid, channelID, remoteID).Return(nil, &notFoundError{})
mockSCStore.On("SaveUser", mock.MatchedBy(func(scu *model.SharedChannelUser) bool { return scu.UserId == uid })).
Return(&model.SharedChannelUser{}, nil)
}
mockCMHStore.On("GetMembershipChanges", channelID, int64(0), batchSize).
Return(histories, nil)
sd := &syncData{
task: syncTask{channelID: channelID},
rc: &model.RemoteCluster{RemoteId: remoteID},
scr: &model.SharedChannelRemote{LastMembersSyncAt: 0},
users: make(map[string]*model.User),
}
err := scs.fetchMembershipsForSync(sd)
require.NoError(t, err)
assert.True(t, sd.resultRepeat, "should set resultRepeat when limit is hit")
}
func TestFetchMembershipsForSync_CursorFromSCR(t *testing.T) {
scs, _, mockCMHStore, _, _, _ := setupSendTest(t)
channelID := model.NewId()
remoteID := model.NewId()
// Verify that LastMembersSyncAt from SCR is used as the cursor
mockCMHStore.On("GetMembershipChanges", channelID, int64(5000), mock.AnythingOfType("int")).
Return([]*model.ChannelMemberHistory{}, nil)
sd := &syncData{
task: syncTask{channelID: channelID},
rc: &model.RemoteCluster{RemoteId: remoteID},
scr: &model.SharedChannelRemote{LastMembersSyncAt: 5000},
users: make(map[string]*model.User),
}
err := scs.fetchMembershipsForSync(sd)
require.NoError(t, err)
mockCMHStore.AssertCalled(t, "GetMembershipChanges", channelID, int64(5000), mock.AnythingOfType("int"))
}
func TestFetchMembershipsForSync_RepeatedTimestampsAtBoundary(t *testing.T) {
scs, _, mockCMHStore, mockSCStore, mockUserStore, _ := setupSendTest(t)
channelID := model.NewId()
remoteID := model.NewId()
batchSize := scs.GetMemberSyncBatchSize()
// Create batch where the last entries share the same JoinTime (boundary timestamp)
boundaryTime := int64(5000)
histories := make([]*model.ChannelMemberHistory, batchSize)
for i := range batchSize {
uid := model.NewId()
joinTime := int64(1000 + i)
// Last 3 entries share the same timestamp
if i >= batchSize-3 {
joinTime = boundaryTime
}
histories[i] = &model.ChannelMemberHistory{
ChannelId: channelID,
UserId: uid,
JoinTime: joinTime,
LeaveTime: nil,
}
u := &model.User{Id: uid}
mockUserStore.On("Get", mock.Anything, uid).Return(u, nil)
mockSCStore.On("GetSingleUser", uid, channelID, remoteID).Return(nil, &notFoundError{})
mockSCStore.On("SaveUser", mock.MatchedBy(func(scu *model.SharedChannelUser) bool { return scu.UserId == uid })).
Return(&model.SharedChannelUser{}, nil)
}
// First fetch returns full batch (hits limit)
mockCMHStore.On("GetMembershipChanges", channelID, int64(0), batchSize).
Return(histories, nil)
sd := &syncData{
task: syncTask{channelID: channelID},
rc: &model.RemoteCluster{RemoteId: remoteID},
scr: &model.SharedChannelRemote{LastMembersSyncAt: 0},
users: make(map[string]*model.User),
}
err := scs.fetchMembershipsForSync(sd)
require.NoError(t, err)
assert.True(t, sd.resultRepeat, "should signal more data when limit hit")
assert.Len(t, sd.membershipChanges, batchSize, "all entries in batch should be processed")
assert.Equal(t, boundaryTime, sd.resultNextMembershipCursor, "cursor should be at boundary timestamp")
// Second fetch uses the boundary timestamp as cursor (GtOrEq means events
// AT boundaryTime will be re-fetched, but deduplication handles this)
extraUser := model.NewId()
mockCMHStore.On("GetMembershipChanges", channelID, boundaryTime, batchSize).
Return([]*model.ChannelMemberHistory{
// Re-fetched rows at boundary (already processed — dedup will handle)
histories[batchSize-3],
histories[batchSize-2],
histories[batchSize-1],
// New row beyond the boundary
{ChannelId: channelID, UserId: extraUser, JoinTime: boundaryTime + 1, LeaveTime: nil},
}, nil)
extraUserObj := &model.User{Id: extraUser}
mockUserStore.On("Get", mock.Anything, extraUser).Return(extraUserObj, nil)
mockSCStore.On("GetSingleUser", extraUser, channelID, remoteID).Return(nil, &notFoundError{})
mockSCStore.On("SaveUser", mock.MatchedBy(func(scu *model.SharedChannelUser) bool { return scu.UserId == extraUser })).
Return(&model.SharedChannelUser{}, nil)
sd2 := &syncData{
task: syncTask{channelID: channelID},
rc: &model.RemoteCluster{RemoteId: remoteID},
scr: &model.SharedChannelRemote{LastMembersSyncAt: boundaryTime},
users: make(map[string]*model.User),
}
err = scs.fetchMembershipsForSync(sd2)
require.NoError(t, err)
// Should have 4 entries (3 re-fetched + 1 new) — dedup at the caller level
// handles duplicate user IDs across batches
assert.Len(t, sd2.membershipChanges, 4, "should include re-fetched boundary rows and new row")
assert.Equal(t, boundaryTime+1, sd2.resultNextMembershipCursor, "cursor should advance past boundary")
}
func TestFetchMembershipsForSync_SkipsUsersFromTargetRemote(t *testing.T) {
scs, _, mockCMHStore, mockSCStore, mockUserStore, _ := setupSendTest(t)
channelID := model.NewId()
remoteID := model.NewId()
localUser := model.NewId() // local to sender, will be synced
targetRemoteUser := model.NewId() // originated from target remote, should be skipped
mockCMHStore.On("GetMembershipChanges", channelID, int64(0), mock.AnythingOfType("int")).
Return([]*model.ChannelMemberHistory{
{ChannelId: channelID, UserId: localUser, JoinTime: 1000, LeaveTime: nil},
{ChannelId: channelID, UserId: targetRemoteUser, JoinTime: 2000, LeaveTime: nil},
}, nil)
// Local user — no RemoteId, should pass the filter
mockUserStore.On("Get", mock.Anything, localUser).Return(&model.User{Id: localUser}, nil)
mockSCStore.On("GetSingleUser", localUser, channelID, remoteID).Return(nil, &notFoundError{})
mockSCStore.On("SaveUser", mock.MatchedBy(func(scu *model.SharedChannelUser) bool { return scu.UserId == localUser })).
Return(&model.SharedChannelUser{}, nil)
// User from target remote — should be filtered out
mockUserStore.On("Get", mock.Anything, targetRemoteUser).Return(&model.User{Id: targetRemoteUser, RemoteId: &remoteID}, nil)
sd := &syncData{
task: syncTask{channelID: channelID},
rc: &model.RemoteCluster{RemoteId: remoteID},
scr: &model.SharedChannelRemote{LastMembersSyncAt: 0},
users: make(map[string]*model.User),
}
err := scs.fetchMembershipsForSync(sd)
require.NoError(t, err)
require.Len(t, sd.membershipChanges, 1, "should only include local user, not user from target remote")
assert.Equal(t, localUser, sd.membershipChanges[0].UserId)
assert.True(t, sd.membershipChanges[0].IsAdd)
}
// notFoundError implements the errNotFound interface used by the sharedchannel package
type notFoundError struct{}
func (e *notFoundError) Error() string { return "not found" }
func (e *notFoundError) IsErrNotFound() bool { return true }

View File

@@ -0,0 +1,857 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
// Regenerate this file using `make sharedchannel-mocks`.
package sharedchannel
import (
model "github.com/mattermost/mattermost/server/public/model"
request "github.com/mattermost/mattermost/server/public/shared/request"
filestore "github.com/mattermost/mattermost/server/v8/platform/shared/filestore"
mock "github.com/stretchr/testify/mock"
)
// MockAppIface is an autogenerated mock type for the AppIface type
type MockAppIface struct {
mock.Mock
}
// AddUserToChannel provides a mock function with given fields: rctx, user, channel, skipTeamMemberIntegrityCheck
func (_m *MockAppIface) AddUserToChannel(rctx request.CTX, user *model.User, channel *model.Channel, skipTeamMemberIntegrityCheck bool) (*model.ChannelMember, *model.AppError) {
ret := _m.Called(rctx, user, channel, skipTeamMemberIntegrityCheck)
if len(ret) == 0 {
panic("no return value specified for AddUserToChannel")
}
var r0 *model.ChannelMember
var r1 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, *model.User, *model.Channel, bool) (*model.ChannelMember, *model.AppError)); ok {
return rf(rctx, user, channel, skipTeamMemberIntegrityCheck)
}
if rf, ok := ret.Get(0).(func(request.CTX, *model.User, *model.Channel, bool) *model.ChannelMember); ok {
r0 = rf(rctx, user, channel, skipTeamMemberIntegrityCheck)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.ChannelMember)
}
}
if rf, ok := ret.Get(1).(func(request.CTX, *model.User, *model.Channel, bool) *model.AppError); ok {
r1 = rf(rctx, user, channel, skipTeamMemberIntegrityCheck)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*model.AppError)
}
}
return r0, r1
}
// AddUserToTeamByTeamId provides a mock function with given fields: rctx, teamId, user
func (_m *MockAppIface) AddUserToTeamByTeamId(rctx request.CTX, teamId string, user *model.User) *model.AppError {
ret := _m.Called(rctx, teamId, user)
if len(ret) == 0 {
panic("no return value specified for AddUserToTeamByTeamId")
}
var r0 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, string, *model.User) *model.AppError); ok {
r0 = rf(rctx, teamId, user)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.AppError)
}
}
return r0
}
// CreateChannelWithUser provides a mock function with given fields: rctx, channel, userId
func (_m *MockAppIface) CreateChannelWithUser(rctx request.CTX, channel *model.Channel, userId string) (*model.Channel, *model.AppError) {
ret := _m.Called(rctx, channel, userId)
if len(ret) == 0 {
panic("no return value specified for CreateChannelWithUser")
}
var r0 *model.Channel
var r1 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, *model.Channel, string) (*model.Channel, *model.AppError)); ok {
return rf(rctx, channel, userId)
}
if rf, ok := ret.Get(0).(func(request.CTX, *model.Channel, string) *model.Channel); ok {
r0 = rf(rctx, channel, userId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Channel)
}
}
if rf, ok := ret.Get(1).(func(request.CTX, *model.Channel, string) *model.AppError); ok {
r1 = rf(rctx, channel, userId)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*model.AppError)
}
}
return r0, r1
}
// CreateGroupChannel provides a mock function with given fields: rctx, userIDs, creatorId, channelOptions
func (_m *MockAppIface) CreateGroupChannel(rctx request.CTX, userIDs []string, creatorId string, channelOptions ...model.ChannelOption) (*model.Channel, *model.AppError) {
_va := make([]interface{}, len(channelOptions))
for _i := range channelOptions {
_va[_i] = channelOptions[_i]
}
var _ca []interface{}
_ca = append(_ca, rctx, userIDs, creatorId)
_ca = append(_ca, _va...)
ret := _m.Called(_ca...)
if len(ret) == 0 {
panic("no return value specified for CreateGroupChannel")
}
var r0 *model.Channel
var r1 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, []string, string, ...model.ChannelOption) (*model.Channel, *model.AppError)); ok {
return rf(rctx, userIDs, creatorId, channelOptions...)
}
if rf, ok := ret.Get(0).(func(request.CTX, []string, string, ...model.ChannelOption) *model.Channel); ok {
r0 = rf(rctx, userIDs, creatorId, channelOptions...)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Channel)
}
}
if rf, ok := ret.Get(1).(func(request.CTX, []string, string, ...model.ChannelOption) *model.AppError); ok {
r1 = rf(rctx, userIDs, creatorId, channelOptions...)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*model.AppError)
}
}
return r0, r1
}
// CreatePost provides a mock function with given fields: rctx, post, channel, flags
func (_m *MockAppIface) CreatePost(rctx request.CTX, post *model.Post, channel *model.Channel, flags model.CreatePostFlags) (*model.Post, bool, *model.AppError) {
ret := _m.Called(rctx, post, channel, flags)
if len(ret) == 0 {
panic("no return value specified for CreatePost")
}
var r0 *model.Post
var r1 bool
var r2 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, *model.Post, *model.Channel, model.CreatePostFlags) (*model.Post, bool, *model.AppError)); ok {
return rf(rctx, post, channel, flags)
}
if rf, ok := ret.Get(0).(func(request.CTX, *model.Post, *model.Channel, model.CreatePostFlags) *model.Post); ok {
r0 = rf(rctx, post, channel, flags)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Post)
}
}
if rf, ok := ret.Get(1).(func(request.CTX, *model.Post, *model.Channel, model.CreatePostFlags) bool); ok {
r1 = rf(rctx, post, channel, flags)
} else {
r1 = ret.Get(1).(bool)
}
if rf, ok := ret.Get(2).(func(request.CTX, *model.Post, *model.Channel, model.CreatePostFlags) *model.AppError); ok {
r2 = rf(rctx, post, channel, flags)
} else {
if ret.Get(2) != nil {
r2 = ret.Get(2).(*model.AppError)
}
}
return r0, r1, r2
}
// CreateUploadSession provides a mock function with given fields: rctx, us
func (_m *MockAppIface) CreateUploadSession(rctx request.CTX, us *model.UploadSession) (*model.UploadSession, *model.AppError) {
ret := _m.Called(rctx, us)
if len(ret) == 0 {
panic("no return value specified for CreateUploadSession")
}
var r0 *model.UploadSession
var r1 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, *model.UploadSession) (*model.UploadSession, *model.AppError)); ok {
return rf(rctx, us)
}
if rf, ok := ret.Get(0).(func(request.CTX, *model.UploadSession) *model.UploadSession); ok {
r0 = rf(rctx, us)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.UploadSession)
}
}
if rf, ok := ret.Get(1).(func(request.CTX, *model.UploadSession) *model.AppError); ok {
r1 = rf(rctx, us)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*model.AppError)
}
}
return r0, r1
}
// DeleteAcknowledgementForPostWithModel provides a mock function with given fields: rctx, acknowledgement
func (_m *MockAppIface) DeleteAcknowledgementForPostWithModel(rctx request.CTX, acknowledgement *model.PostAcknowledgement) *model.AppError {
ret := _m.Called(rctx, acknowledgement)
if len(ret) == 0 {
panic("no return value specified for DeleteAcknowledgementForPostWithModel")
}
var r0 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, *model.PostAcknowledgement) *model.AppError); ok {
r0 = rf(rctx, acknowledgement)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.AppError)
}
}
return r0
}
// DeletePost provides a mock function with given fields: rctx, postID, deleteByID
func (_m *MockAppIface) DeletePost(rctx request.CTX, postID string, deleteByID string) (*model.Post, *model.AppError) {
ret := _m.Called(rctx, postID, deleteByID)
if len(ret) == 0 {
panic("no return value specified for DeletePost")
}
var r0 *model.Post
var r1 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, string, string) (*model.Post, *model.AppError)); ok {
return rf(rctx, postID, deleteByID)
}
if rf, ok := ret.Get(0).(func(request.CTX, string, string) *model.Post); ok {
r0 = rf(rctx, postID, deleteByID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Post)
}
}
if rf, ok := ret.Get(1).(func(request.CTX, string, string) *model.AppError); ok {
r1 = rf(rctx, postID, deleteByID)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*model.AppError)
}
}
return r0, r1
}
// DeleteReactionForPost provides a mock function with given fields: rctx, reaction
func (_m *MockAppIface) DeleteReactionForPost(rctx request.CTX, reaction *model.Reaction) *model.AppError {
ret := _m.Called(rctx, reaction)
if len(ret) == 0 {
panic("no return value specified for DeleteReactionForPost")
}
var r0 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, *model.Reaction) *model.AppError); ok {
r0 = rf(rctx, reaction)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.AppError)
}
}
return r0
}
// FileReader provides a mock function with given fields: path
func (_m *MockAppIface) FileReader(path string) (filestore.ReadCloseSeeker, *model.AppError) {
ret := _m.Called(path)
if len(ret) == 0 {
panic("no return value specified for FileReader")
}
var r0 filestore.ReadCloseSeeker
var r1 *model.AppError
if rf, ok := ret.Get(0).(func(string) (filestore.ReadCloseSeeker, *model.AppError)); ok {
return rf(path)
}
if rf, ok := ret.Get(0).(func(string) filestore.ReadCloseSeeker); ok {
r0 = rf(path)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(filestore.ReadCloseSeeker)
}
}
if rf, ok := ret.Get(1).(func(string) *model.AppError); ok {
r1 = rf(path)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*model.AppError)
}
}
return r0, r1
}
// GetAcknowledgementsForPost provides a mock function with given fields: postID
func (_m *MockAppIface) GetAcknowledgementsForPost(postID string) ([]*model.PostAcknowledgement, *model.AppError) {
ret := _m.Called(postID)
if len(ret) == 0 {
panic("no return value specified for GetAcknowledgementsForPost")
}
var r0 []*model.PostAcknowledgement
var r1 *model.AppError
if rf, ok := ret.Get(0).(func(string) ([]*model.PostAcknowledgement, *model.AppError)); ok {
return rf(postID)
}
if rf, ok := ret.Get(0).(func(string) []*model.PostAcknowledgement); ok {
r0 = rf(postID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*model.PostAcknowledgement)
}
}
if rf, ok := ret.Get(1).(func(string) *model.AppError); ok {
r1 = rf(postID)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*model.AppError)
}
}
return r0, r1
}
// GetOrCreateDirectChannel provides a mock function with given fields: rctx, userId, otherUserId, channelOptions
func (_m *MockAppIface) GetOrCreateDirectChannel(rctx request.CTX, userId string, otherUserId string, channelOptions ...model.ChannelOption) (*model.Channel, *model.AppError) {
_va := make([]interface{}, len(channelOptions))
for _i := range channelOptions {
_va[_i] = channelOptions[_i]
}
var _ca []interface{}
_ca = append(_ca, rctx, userId, otherUserId)
_ca = append(_ca, _va...)
ret := _m.Called(_ca...)
if len(ret) == 0 {
panic("no return value specified for GetOrCreateDirectChannel")
}
var r0 *model.Channel
var r1 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, string, string, ...model.ChannelOption) (*model.Channel, *model.AppError)); ok {
return rf(rctx, userId, otherUserId, channelOptions...)
}
if rf, ok := ret.Get(0).(func(request.CTX, string, string, ...model.ChannelOption) *model.Channel); ok {
r0 = rf(rctx, userId, otherUserId, channelOptions...)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Channel)
}
}
if rf, ok := ret.Get(1).(func(request.CTX, string, string, ...model.ChannelOption) *model.AppError); ok {
r1 = rf(rctx, userId, otherUserId, channelOptions...)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*model.AppError)
}
}
return r0, r1
}
// GetProfileImage provides a mock function with given fields: user
func (_m *MockAppIface) GetProfileImage(user *model.User) ([]byte, bool, *model.AppError) {
ret := _m.Called(user)
if len(ret) == 0 {
panic("no return value specified for GetProfileImage")
}
var r0 []byte
var r1 bool
var r2 *model.AppError
if rf, ok := ret.Get(0).(func(*model.User) ([]byte, bool, *model.AppError)); ok {
return rf(user)
}
if rf, ok := ret.Get(0).(func(*model.User) []byte); ok {
r0 = rf(user)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]byte)
}
}
if rf, ok := ret.Get(1).(func(*model.User) bool); ok {
r1 = rf(user)
} else {
r1 = ret.Get(1).(bool)
}
if rf, ok := ret.Get(2).(func(*model.User) *model.AppError); ok {
r2 = rf(user)
} else {
if ret.Get(2) != nil {
r2 = ret.Get(2).(*model.AppError)
}
}
return r0, r1, r2
}
// GetSystemBot provides a mock function with given fields: rctx
func (_m *MockAppIface) GetSystemBot(rctx request.CTX) (*model.Bot, *model.AppError) {
ret := _m.Called(rctx)
if len(ret) == 0 {
panic("no return value specified for GetSystemBot")
}
var r0 *model.Bot
var r1 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX) (*model.Bot, *model.AppError)); ok {
return rf(rctx)
}
if rf, ok := ret.Get(0).(func(request.CTX) *model.Bot); ok {
r0 = rf(rctx)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Bot)
}
}
if rf, ok := ret.Get(1).(func(request.CTX) *model.AppError); ok {
r1 = rf(rctx)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*model.AppError)
}
}
return r0, r1
}
// MentionsToTeamMembers provides a mock function with given fields: rctx, message, teamID
func (_m *MockAppIface) MentionsToTeamMembers(rctx request.CTX, message string, teamID string) model.UserMentionMap {
ret := _m.Called(rctx, message, teamID)
if len(ret) == 0 {
panic("no return value specified for MentionsToTeamMembers")
}
var r0 model.UserMentionMap
if rf, ok := ret.Get(0).(func(request.CTX, string, string) model.UserMentionMap); ok {
r0 = rf(rctx, message, teamID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(model.UserMentionMap)
}
}
return r0
}
// NotifySharedChannelUserUpdate provides a mock function with given fields: user
func (_m *MockAppIface) NotifySharedChannelUserUpdate(user *model.User) {
_m.Called(user)
}
// OnSharedChannelsAttachmentSyncMsg provides a mock function with given fields: fi, post, rc
func (_m *MockAppIface) OnSharedChannelsAttachmentSyncMsg(fi *model.FileInfo, post *model.Post, rc *model.RemoteCluster) error {
ret := _m.Called(fi, post, rc)
if len(ret) == 0 {
panic("no return value specified for OnSharedChannelsAttachmentSyncMsg")
}
var r0 error
if rf, ok := ret.Get(0).(func(*model.FileInfo, *model.Post, *model.RemoteCluster) error); ok {
r0 = rf(fi, post, rc)
} else {
r0 = ret.Error(0)
}
return r0
}
// OnSharedChannelsProfileImageSyncMsg provides a mock function with given fields: user, rc
func (_m *MockAppIface) OnSharedChannelsProfileImageSyncMsg(user *model.User, rc *model.RemoteCluster) error {
ret := _m.Called(user, rc)
if len(ret) == 0 {
panic("no return value specified for OnSharedChannelsProfileImageSyncMsg")
}
var r0 error
if rf, ok := ret.Get(0).(func(*model.User, *model.RemoteCluster) error); ok {
r0 = rf(user, rc)
} else {
r0 = ret.Error(0)
}
return r0
}
// OnSharedChannelsSyncMsg provides a mock function with given fields: msg, rc
func (_m *MockAppIface) OnSharedChannelsSyncMsg(msg *model.SyncMsg, rc *model.RemoteCluster) (model.SyncResponse, error) {
ret := _m.Called(msg, rc)
if len(ret) == 0 {
panic("no return value specified for OnSharedChannelsSyncMsg")
}
var r0 model.SyncResponse
var r1 error
if rf, ok := ret.Get(0).(func(*model.SyncMsg, *model.RemoteCluster) (model.SyncResponse, error)); ok {
return rf(msg, rc)
}
if rf, ok := ret.Get(0).(func(*model.SyncMsg, *model.RemoteCluster) model.SyncResponse); ok {
r0 = rf(msg, rc)
} else {
r0 = ret.Get(0).(model.SyncResponse)
}
if rf, ok := ret.Get(1).(func(*model.SyncMsg, *model.RemoteCluster) error); ok {
r1 = rf(msg, rc)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// PatchChannelModerationsForChannel provides a mock function with given fields: rctx, channel, channelModerationsPatch
func (_m *MockAppIface) PatchChannelModerationsForChannel(rctx request.CTX, channel *model.Channel, channelModerationsPatch []*model.ChannelModerationPatch) ([]*model.ChannelModeration, *model.AppError) {
ret := _m.Called(rctx, channel, channelModerationsPatch)
if len(ret) == 0 {
panic("no return value specified for PatchChannelModerationsForChannel")
}
var r0 []*model.ChannelModeration
var r1 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, *model.Channel, []*model.ChannelModerationPatch) ([]*model.ChannelModeration, *model.AppError)); ok {
return rf(rctx, channel, channelModerationsPatch)
}
if rf, ok := ret.Get(0).(func(request.CTX, *model.Channel, []*model.ChannelModerationPatch) []*model.ChannelModeration); ok {
r0 = rf(rctx, channel, channelModerationsPatch)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*model.ChannelModeration)
}
}
if rf, ok := ret.Get(1).(func(request.CTX, *model.Channel, []*model.ChannelModerationPatch) *model.AppError); ok {
r1 = rf(rctx, channel, channelModerationsPatch)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*model.AppError)
}
}
return r0, r1
}
// PermanentDeleteChannel provides a mock function with given fields: rctx, channel
func (_m *MockAppIface) PermanentDeleteChannel(rctx request.CTX, channel *model.Channel) *model.AppError {
ret := _m.Called(rctx, channel)
if len(ret) == 0 {
panic("no return value specified for PermanentDeleteChannel")
}
var r0 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, *model.Channel) *model.AppError); ok {
r0 = rf(rctx, channel)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.AppError)
}
}
return r0
}
// PreparePostForClient provides a mock function with given fields: rctx, post, opts
func (_m *MockAppIface) PreparePostForClient(rctx request.CTX, post *model.Post, opts *model.PreparePostForClientOpts) *model.Post {
ret := _m.Called(rctx, post, opts)
if len(ret) == 0 {
panic("no return value specified for PreparePostForClient")
}
var r0 *model.Post
if rf, ok := ret.Get(0).(func(request.CTX, *model.Post, *model.PreparePostForClientOpts) *model.Post); ok {
r0 = rf(rctx, post, opts)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Post)
}
}
return r0
}
// Publish provides a mock function with given fields: message
func (_m *MockAppIface) Publish(message *model.WebSocketEvent) {
_m.Called(message)
}
// RemoveUserFromChannel provides a mock function with given fields: rctx, userID, removerUserId, channel
func (_m *MockAppIface) RemoveUserFromChannel(rctx request.CTX, userID string, removerUserId string, channel *model.Channel) *model.AppError {
ret := _m.Called(rctx, userID, removerUserId, channel)
if len(ret) == 0 {
panic("no return value specified for RemoveUserFromChannel")
}
var r0 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, string, string, *model.Channel) *model.AppError); ok {
r0 = rf(rctx, userID, removerUserId, channel)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.AppError)
}
}
return r0
}
// SaveAcknowledgementForPostWithModel provides a mock function with given fields: rctx, acknowledgement
func (_m *MockAppIface) SaveAcknowledgementForPostWithModel(rctx request.CTX, acknowledgement *model.PostAcknowledgement) (*model.PostAcknowledgement, *model.AppError) {
ret := _m.Called(rctx, acknowledgement)
if len(ret) == 0 {
panic("no return value specified for SaveAcknowledgementForPostWithModel")
}
var r0 *model.PostAcknowledgement
var r1 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, *model.PostAcknowledgement) (*model.PostAcknowledgement, *model.AppError)); ok {
return rf(rctx, acknowledgement)
}
if rf, ok := ret.Get(0).(func(request.CTX, *model.PostAcknowledgement) *model.PostAcknowledgement); ok {
r0 = rf(rctx, acknowledgement)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.PostAcknowledgement)
}
}
if rf, ok := ret.Get(1).(func(request.CTX, *model.PostAcknowledgement) *model.AppError); ok {
r1 = rf(rctx, acknowledgement)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*model.AppError)
}
}
return r0, r1
}
// SaveAcknowledgementsForPost provides a mock function with given fields: rctx, postID, userIDs
func (_m *MockAppIface) SaveAcknowledgementsForPost(rctx request.CTX, postID string, userIDs []string) ([]*model.PostAcknowledgement, *model.AppError) {
ret := _m.Called(rctx, postID, userIDs)
if len(ret) == 0 {
panic("no return value specified for SaveAcknowledgementsForPost")
}
var r0 []*model.PostAcknowledgement
var r1 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, string, []string) ([]*model.PostAcknowledgement, *model.AppError)); ok {
return rf(rctx, postID, userIDs)
}
if rf, ok := ret.Get(0).(func(request.CTX, string, []string) []*model.PostAcknowledgement); ok {
r0 = rf(rctx, postID, userIDs)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*model.PostAcknowledgement)
}
}
if rf, ok := ret.Get(1).(func(request.CTX, string, []string) *model.AppError); ok {
r1 = rf(rctx, postID, userIDs)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*model.AppError)
}
}
return r0, r1
}
// SaveAndBroadcastStatus provides a mock function with given fields: status
func (_m *MockAppIface) SaveAndBroadcastStatus(status *model.Status) {
_m.Called(status)
}
// SaveReactionForPost provides a mock function with given fields: rctx, reaction
func (_m *MockAppIface) SaveReactionForPost(rctx request.CTX, reaction *model.Reaction) (*model.Reaction, *model.AppError) {
ret := _m.Called(rctx, reaction)
if len(ret) == 0 {
panic("no return value specified for SaveReactionForPost")
}
var r0 *model.Reaction
var r1 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, *model.Reaction) (*model.Reaction, *model.AppError)); ok {
return rf(rctx, reaction)
}
if rf, ok := ret.Get(0).(func(request.CTX, *model.Reaction) *model.Reaction); ok {
r0 = rf(rctx, reaction)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Reaction)
}
}
if rf, ok := ret.Get(1).(func(request.CTX, *model.Reaction) *model.AppError); ok {
r1 = rf(rctx, reaction)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*model.AppError)
}
}
return r0, r1
}
// SendEphemeralPost provides a mock function with given fields: rctx, userId, post
func (_m *MockAppIface) SendEphemeralPost(rctx request.CTX, userId string, post *model.Post) (*model.Post, bool) {
ret := _m.Called(rctx, userId, post)
if len(ret) == 0 {
panic("no return value specified for SendEphemeralPost")
}
var r0 *model.Post
var r1 bool
if rf, ok := ret.Get(0).(func(request.CTX, string, *model.Post) (*model.Post, bool)); ok {
return rf(rctx, userId, post)
}
if rf, ok := ret.Get(0).(func(request.CTX, string, *model.Post) *model.Post); ok {
r0 = rf(rctx, userId, post)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Post)
}
}
if rf, ok := ret.Get(1).(func(request.CTX, string, *model.Post) bool); ok {
r1 = rf(rctx, userId, post)
} else {
r1 = ret.Get(1).(bool)
}
return r0, r1
}
// UpdatePost provides a mock function with given fields: rctx, post, updatePostOptions
func (_m *MockAppIface) UpdatePost(rctx request.CTX, post *model.Post, updatePostOptions *model.UpdatePostOptions) (*model.Post, bool, *model.AppError) {
ret := _m.Called(rctx, post, updatePostOptions)
if len(ret) == 0 {
panic("no return value specified for UpdatePost")
}
var r0 *model.Post
var r1 bool
var r2 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, *model.Post, *model.UpdatePostOptions) (*model.Post, bool, *model.AppError)); ok {
return rf(rctx, post, updatePostOptions)
}
if rf, ok := ret.Get(0).(func(request.CTX, *model.Post, *model.UpdatePostOptions) *model.Post); ok {
r0 = rf(rctx, post, updatePostOptions)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Post)
}
}
if rf, ok := ret.Get(1).(func(request.CTX, *model.Post, *model.UpdatePostOptions) bool); ok {
r1 = rf(rctx, post, updatePostOptions)
} else {
r1 = ret.Get(1).(bool)
}
if rf, ok := ret.Get(2).(func(request.CTX, *model.Post, *model.UpdatePostOptions) *model.AppError); ok {
r2 = rf(rctx, post, updatePostOptions)
} else {
if ret.Get(2) != nil {
r2 = ret.Get(2).(*model.AppError)
}
}
return r0, r1, r2
}
// UserCanSeeOtherUser provides a mock function with given fields: rctx, userID, otherUserId
func (_m *MockAppIface) UserCanSeeOtherUser(rctx request.CTX, userID string, otherUserId string) (bool, *model.AppError) {
ret := _m.Called(rctx, userID, otherUserId)
if len(ret) == 0 {
panic("no return value specified for UserCanSeeOtherUser")
}
var r0 bool
var r1 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, string, string) (bool, *model.AppError)); ok {
return rf(rctx, userID, otherUserId)
}
if rf, ok := ret.Get(0).(func(request.CTX, string, string) bool); ok {
r0 = rf(rctx, userID, otherUserId)
} else {
r0 = ret.Get(0).(bool)
}
if rf, ok := ret.Get(1).(func(request.CTX, string, string) *model.AppError); ok {
r1 = rf(rctx, userID, otherUserId)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*model.AppError)
}
}
return r0, r1
}
// NewMockAppIface creates a new instance of MockAppIface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockAppIface(t interface {
mock.TestingT
Cleanup(func())
}) *MockAppIface {
mock := &MockAppIface{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -0,0 +1,174 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
// Regenerate this file using `make sharedchannel-mocks`.
package sharedchannel
import (
model "github.com/mattermost/mattermost/server/public/model"
mlog "github.com/mattermost/mattermost/server/public/shared/mlog"
store "github.com/mattermost/mattermost/server/v8/channels/store"
einterfaces "github.com/mattermost/mattermost/server/v8/einterfaces"
remotecluster "github.com/mattermost/mattermost/server/v8/platform/services/remotecluster"
mock "github.com/stretchr/testify/mock"
)
// MockServerIface is an autogenerated mock type for the ServerIface type
type MockServerIface struct {
mock.Mock
}
// AddClusterLeaderChangedListener provides a mock function with given fields: listener
func (_m *MockServerIface) AddClusterLeaderChangedListener(listener func()) string {
ret := _m.Called(listener)
if len(ret) == 0 {
panic("no return value specified for AddClusterLeaderChangedListener")
}
var r0 string
if rf, ok := ret.Get(0).(func(func()) string); ok {
r0 = rf(listener)
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// Config provides a mock function with no fields
func (_m *MockServerIface) Config() *model.Config {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for Config")
}
var r0 *model.Config
if rf, ok := ret.Get(0).(func() *model.Config); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Config)
}
}
return r0
}
// GetMetrics provides a mock function with no fields
func (_m *MockServerIface) GetMetrics() einterfaces.MetricsInterface {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetMetrics")
}
var r0 einterfaces.MetricsInterface
if rf, ok := ret.Get(0).(func() einterfaces.MetricsInterface); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(einterfaces.MetricsInterface)
}
}
return r0
}
// GetRemoteClusterService provides a mock function with no fields
func (_m *MockServerIface) GetRemoteClusterService() remotecluster.RemoteClusterServiceIFace {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetRemoteClusterService")
}
var r0 remotecluster.RemoteClusterServiceIFace
if rf, ok := ret.Get(0).(func() remotecluster.RemoteClusterServiceIFace); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(remotecluster.RemoteClusterServiceIFace)
}
}
return r0
}
// GetStore provides a mock function with no fields
func (_m *MockServerIface) GetStore() store.Store {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetStore")
}
var r0 store.Store
if rf, ok := ret.Get(0).(func() store.Store); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(store.Store)
}
}
return r0
}
// IsLeader provides a mock function with no fields
func (_m *MockServerIface) IsLeader() bool {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for IsLeader")
}
var r0 bool
if rf, ok := ret.Get(0).(func() bool); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(bool)
}
return r0
}
// Log provides a mock function with no fields
func (_m *MockServerIface) Log() *mlog.Logger {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for Log")
}
var r0 *mlog.Logger
if rf, ok := ret.Get(0).(func() *mlog.Logger); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*mlog.Logger)
}
}
return r0
}
// RemoveClusterLeaderChangedListener provides a mock function with given fields: id
func (_m *MockServerIface) RemoveClusterLeaderChangedListener(id string) {
_m.Called(id)
}
// NewMockServerIface creates a new instance of MockServerIface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockServerIface(t interface {
mock.TestingT
Cleanup(func())
}) *MockServerIface {
mock := &MockServerIface{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -0,0 +1,88 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sharedchannel
import (
"net/url"
"regexp"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
)
var (
// Team name regex taken from model.IsValidTeamName
permaLinkRegex = regexp.MustCompile(`https?://[0-9.\-A-Za-z]+/[a-z0-9]+([a-z\-0-9]+|(__)?)[a-z0-9]+/pl/([a-zA-Z0-9]+)`)
permaLinkSharedRegex = regexp.MustCompile(`https?://[0-9.\-A-Za-z]+/[a-z0-9]+([a-z\-0-9]+|(__)?)[a-z0-9]+/plshared/([a-zA-Z0-9]+)`)
)
const (
permalinkMarker = "plshared"
)
// processPermalinkToRemote processes all permalinks going towards a remote site.
func (scs *Service) processPermalinkToRemote(p *model.Post) string {
// request.CTX is not widely used in the shared channels code.
// Just create a new request.CTX for now.
rctx := request.EmptyContext(scs.server.Log())
var sent bool
return permaLinkRegex.ReplaceAllStringFunc(p.Message, func(msg string) string {
// Extract the postID (This is simple enough not to warrant full-blown URL parsing.)
lastSlash := strings.LastIndexByte(msg, '/')
postID := msg[lastSlash+1:]
opts := model.GetPostsOptions{
SkipFetchThreads: true,
}
postList, err := scs.server.GetStore().Post().Get(rctx, postID, opts, "", map[string]bool{})
if err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "Unable to get post during replacing permalinks", mlog.Err(err))
return msg
}
if len(postList.Order) == 0 {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "No post found for permalink", mlog.String("post_id", postID))
return msg
}
// If postID is for a different channel
if postList.Posts[postList.Order[0]].ChannelId != p.ChannelId {
// Send ephemeral message to OP (only once per message).
if !sent {
scs.sendEphemeralPost(p.ChannelId, p.UserId, i18n.T("sharedchannel.permalink.not_found"))
sent = true
}
// But don't modify msg
return msg
}
// Otherwise, modify pl to plshared as a marker to be replaced by remote sites
return strings.Replace(msg, "/pl/", "/"+permalinkMarker+"/", 1)
})
}
// processPermalinkFromRemote processes all permalinks coming from a remote site.
func (scs *Service) processPermalinkFromRemote(p *model.Post, team *model.Team) string {
return permaLinkSharedRegex.ReplaceAllStringFunc(p.Message, func(remoteLink string) string {
// Extract host name
parsed, err := url.Parse(remoteLink)
if err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "Unable to parse the remote link during replacing permalinks", mlog.Err(err))
return remoteLink
}
// Replace with local SiteURL
parsed.Scheme = scs.siteURL.Scheme
parsed.Host = scs.siteURL.Host
// Replace team name with local team
teamEnd := strings.Index(parsed.Path, "/"+permalinkMarker)
parsed.Path = "/" + team.Name + parsed.Path[teamEnd:]
// Replace plshared with pl
return strings.Replace(parsed.String(), "/"+permalinkMarker+"/", "/pl/", 1)
})
}

View File

@@ -0,0 +1,113 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sharedchannel
import (
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin/plugintest/mock"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
"github.com/mattermost/mattermost/server/v8/channels/utils"
)
func TestProcessPermalinkToRemote(t *testing.T) {
scs := &Service{
server: &MockServerIface{},
app: &MockAppIface{},
}
mockStore := &mocks.Store{}
mockPostStore := mocks.PostStore{}
utils.TranslationsPreInit()
logger := mlog.CreateConsoleTestLogger(t)
pl := &model.PostList{}
mockPostStore.On("Get", mock.MatchedBy(func(ctx *request.Context) bool { return true }), "postID", model.GetPostsOptions{SkipFetchThreads: true}, "", map[string]bool{}).Return(pl, nil)
mockStore.On("Post").Return(&mockPostStore)
mockServer := scs.server.(*MockServerIface)
mockServer.On("GetStore").Return(mockStore)
mockServer.On("Log").Return(logger)
mockApp := scs.app.(*MockAppIface)
mockApp.On("SendEphemeralPost", mock.Anything, "user", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, true).Times(1)
defer mockApp.AssertExpectations(t)
t.Run("same channel", func(t *testing.T) {
post := &model.Post{
Message: "hello world https://comm.matt.com/team/pl/postID link",
ChannelId: "sourceChan",
UserId: "user",
}
*pl = model.PostList{
Order: []string{"1"},
Posts: map[string]*model.Post{
"1": {
ChannelId: "sourceChan",
UserId: "user",
},
},
}
out := scs.processPermalinkToRemote(post)
assert.Equal(t, "hello world https://comm.matt.com/team/plshared/postID link", out)
})
t.Run("different channel", func(t *testing.T) {
post := &model.Post{
Message: "hello world https://comm.matt.com/team/pl/postID link https://comm.matt.com/team/pl/postID ",
ChannelId: "sourceChan",
UserId: "user",
}
*pl = model.PostList{
Order: []string{"1"},
Posts: map[string]*model.Post{
"1": {
ChannelId: "otherChan",
},
},
}
out := scs.processPermalinkToRemote(post)
assert.Equal(t, "hello world https://comm.matt.com/team/pl/postID link https://comm.matt.com/team/pl/postID ", out)
})
}
func TestProcessPermalinkFromRemote(t *testing.T) {
t.Run("has match", func(t *testing.T) {
parsed, _ := url.Parse("http://mysite.com")
scs := &Service{
server: &MockServerIface{},
siteURL: parsed,
}
out := scs.processPermalinkFromRemote(&model.Post{Message: "hello world https://comm.matt.com/team/plshared/postID link"},
&model.Team{Name: "myteam"})
assert.Equal(t,
"hello world http://mysite.com/myteam/pl/postID link",
out)
})
t.Run("does not match", func(t *testing.T) {
parsed, _ := url.Parse("http://mysite.com")
scs := &Service{
server: &MockServerIface{},
siteURL: parsed,
}
out := scs.processPermalinkFromRemote(&model.Post{Message: "hello world https://comm.matt.com/team/pl/postID link"},
&model.Team{Name: "myteam"})
assert.Equal(t,
"hello world https://comm.matt.com/team/pl/postID link",
out)
})
}

View File

@@ -0,0 +1,488 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sharedchannel
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"sync"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/einterfaces"
"github.com/mattermost/mattermost/server/v8/platform/services/remotecluster"
"github.com/mattermost/mattermost/server/v8/platform/shared/filestore"
)
const (
TopicSync = "sharedchannel_sync"
TopicChannelInvite = "sharedchannel_invite"
TopicUploadCreate = "sharedchannel_upload"
TopicChannelMembership = "sharedchannel_membership"
TopicGlobalUserSync = "sharedchannel_global_user_sync"
MaxRetries = 3
MaxUsersPerSync = 25
NotifyRemoteOfflineThreshold = time.Second * 10
NotifyMinimumDelay = time.Second * 2
MaxUpsertRetries = 25
ProfileImageSyncTimeout = time.Second * 5
SyncRetryDelay = time.Second * 15
// Default value for MaxMembersPerBatch is defined in config.go as ConnectedWorkspacesSettingsDefaultMemberSyncBatchSize
)
// Mocks can be re-generated with `make sharedchannel-mocks`.
type ServerIface interface {
Config() *model.Config
IsLeader() bool
AddClusterLeaderChangedListener(listener func()) string
RemoveClusterLeaderChangedListener(id string)
GetStore() store.Store
Log() *mlog.Logger
GetRemoteClusterService() remotecluster.RemoteClusterServiceIFace
GetMetrics() einterfaces.MetricsInterface
}
type PlatformIface interface {
InvalidateCacheForUser(userID string)
InvalidateCacheForChannel(channel *model.Channel)
}
type AppIface interface {
SendEphemeralPost(rctx request.CTX, userId string, post *model.Post) (*model.Post, bool)
CreateChannelWithUser(rctx request.CTX, channel *model.Channel, userId string) (*model.Channel, *model.AppError)
GetOrCreateDirectChannel(rctx request.CTX, userId, otherUserId string, channelOptions ...model.ChannelOption) (*model.Channel, *model.AppError)
CreateGroupChannel(rctx request.CTX, userIDs []string, creatorId string, channelOptions ...model.ChannelOption) (*model.Channel, *model.AppError)
UserCanSeeOtherUser(rctx request.CTX, userID string, otherUserId string) (bool, *model.AppError)
AddUserToChannel(rctx request.CTX, user *model.User, channel *model.Channel, skipTeamMemberIntegrityCheck bool) (*model.ChannelMember, *model.AppError)
AddUserToTeamByTeamId(rctx request.CTX, teamId string, user *model.User) *model.AppError
RemoveUserFromChannel(rctx request.CTX, userID string, removerUserId string, channel *model.Channel) *model.AppError
PermanentDeleteChannel(rctx request.CTX, channel *model.Channel) *model.AppError
CreatePost(rctx request.CTX, post *model.Post, channel *model.Channel, flags model.CreatePostFlags) (savedPost *model.Post, isMemberForPreviews bool, err *model.AppError)
GetSystemBot(rctx request.CTX) (*model.Bot, *model.AppError)
UpdatePost(rctx request.CTX, post *model.Post, updatePostOptions *model.UpdatePostOptions) (*model.Post, bool, *model.AppError)
DeletePost(rctx request.CTX, postID, deleteByID string) (*model.Post, *model.AppError)
SaveReactionForPost(rctx request.CTX, reaction *model.Reaction) (*model.Reaction, *model.AppError)
DeleteReactionForPost(rctx request.CTX, reaction *model.Reaction) *model.AppError
SaveAndBroadcastStatus(status *model.Status)
PatchChannelModerationsForChannel(rctx request.CTX, channel *model.Channel, channelModerationsPatch []*model.ChannelModerationPatch) ([]*model.ChannelModeration, *model.AppError)
CreateUploadSession(rctx request.CTX, us *model.UploadSession) (*model.UploadSession, *model.AppError)
FileReader(path string) (filestore.ReadCloseSeeker, *model.AppError)
MentionsToTeamMembers(rctx request.CTX, message, teamID string) model.UserMentionMap
GetProfileImage(user *model.User) ([]byte, bool, *model.AppError)
NotifySharedChannelUserUpdate(user *model.User)
OnSharedChannelsSyncMsg(msg *model.SyncMsg, rc *model.RemoteCluster) (model.SyncResponse, error)
OnSharedChannelsAttachmentSyncMsg(fi *model.FileInfo, post *model.Post, rc *model.RemoteCluster) error
OnSharedChannelsProfileImageSyncMsg(user *model.User, rc *model.RemoteCluster) error
Publish(message *model.WebSocketEvent)
SaveAcknowledgementForPostWithModel(rctx request.CTX, acknowledgement *model.PostAcknowledgement) (*model.PostAcknowledgement, *model.AppError)
DeleteAcknowledgementForPostWithModel(rctx request.CTX, acknowledgement *model.PostAcknowledgement) *model.AppError
SaveAcknowledgementsForPost(rctx request.CTX, postID string, userIDs []string) ([]*model.PostAcknowledgement, *model.AppError)
GetAcknowledgementsForPost(postID string) ([]*model.PostAcknowledgement, *model.AppError)
PreparePostForClient(rctx request.CTX, post *model.Post, opts *model.PreparePostForClientOpts) *model.Post
}
// errNotFound allows checking against Store.ErrNotFound errors without making Store a dependency.
type errNotFound interface {
IsErrNotFound() bool
}
// Service provides shared channel synchronization.
type Service struct {
server ServerIface
platform PlatformIface
app AppIface
changeSignal chan struct{}
// everything below guarded by `mux`
mux sync.RWMutex
active bool
leaderListenerId string
connectionStateListenerId string
done chan struct{}
tasks map[string]syncTask
syncTopicListenerId string
inviteTopicListenerId string
uploadTopicListenerId string
globalSyncTopicListenerId string
siteURL *url.URL
}
// NewSharedChannelService creates a RemoteClusterService instance.
func NewSharedChannelService(server ServerIface, platform PlatformIface, app AppIface) (*Service, error) {
service := &Service{
server: server,
platform: platform,
app: app,
changeSignal: make(chan struct{}, 1),
tasks: make(map[string]syncTask),
}
parsed, err := url.Parse(*server.Config().ServiceSettings.SiteURL)
if err != nil {
return nil, fmt.Errorf("unable to parse SiteURL: %w", err)
}
service.siteURL = parsed
return service, nil
}
// Start is called by the server on server start-up.
func (scs *Service) Start() error {
rcs := scs.server.GetRemoteClusterService()
if rcs == nil || !rcs.Active() {
return errors.New("Shared Channel Service cannot activate: requires Remote Cluster Service")
}
scs.mux.Lock()
scs.leaderListenerId = scs.server.AddClusterLeaderChangedListener(scs.onClusterLeaderChange)
scs.syncTopicListenerId = rcs.AddTopicListener(TopicSync, scs.onReceiveSyncMessage)
scs.inviteTopicListenerId = rcs.AddTopicListener(TopicChannelInvite, scs.onReceiveChannelInvite)
scs.uploadTopicListenerId = rcs.AddTopicListener(TopicUploadCreate, scs.onReceiveUploadCreate)
scs.globalSyncTopicListenerId = rcs.AddTopicListener(TopicGlobalUserSync, scs.onReceiveSyncMessage)
scs.connectionStateListenerId = rcs.AddConnectionStateListener(scs.onConnectionStateChange)
scs.mux.Unlock()
rcs.AddTopicListener(TopicChannelMembership, scs.onReceiveSyncMessage)
scs.onClusterLeaderChange()
return nil
}
// Shutdown is called by the server on server shutdown.
func (scs *Service) Shutdown() error {
rcs := scs.server.GetRemoteClusterService()
if rcs == nil || !rcs.Active() {
return errors.New("Shared Channel Service cannot shutdown: requires Remote Cluster Service")
}
scs.mux.Lock()
id := scs.leaderListenerId
rcs.RemoveTopicListener(scs.syncTopicListenerId)
scs.syncTopicListenerId = ""
rcs.RemoveTopicListener(scs.inviteTopicListenerId)
scs.inviteTopicListenerId = ""
rcs.RemoveConnectionStateListener(scs.connectionStateListenerId)
scs.connectionStateListenerId = ""
scs.mux.Unlock()
scs.server.RemoveClusterLeaderChangedListener(id)
scs.pause()
return nil
}
// Active determines whether the service is active on the node or not.
func (scs *Service) Active() bool {
scs.mux.Lock()
defer scs.mux.Unlock()
return scs.active
}
func (scs *Service) sendEphemeralPost(channelId string, userId string, text string) {
ephemeral := &model.Post{
ChannelId: channelId,
Message: text,
CreateAt: model.GetMillis(),
}
scs.app.SendEphemeralPost(request.EmptyContext(scs.server.Log()), userId, ephemeral)
}
// onClusterLeaderChange is called whenever the cluster leader may have changed.
func (scs *Service) onClusterLeaderChange() {
if scs.server.IsLeader() {
scs.resume()
} else {
scs.pause()
}
}
func (scs *Service) resume() {
scs.mux.Lock()
defer scs.mux.Unlock()
if scs.active {
return // already active
}
scs.active = true
scs.done = make(chan struct{})
go scs.syncLoop(scs.done)
scs.server.Log().Debug("Shared Channel Service active")
}
func (scs *Service) pause() {
scs.mux.Lock()
defer scs.mux.Unlock()
if !scs.active {
return // already inactive
}
scs.active = false
close(scs.done)
scs.done = nil
scs.server.Log().Debug("Shared Channel Service inactive")
}
// GetMemberSyncBatchSize returns the configured batch size for member synchronization
func (scs *Service) GetMemberSyncBatchSize() int {
if scs.server.Config().ConnectedWorkspacesSettings.MemberSyncBatchSize != nil {
return *scs.server.Config().ConnectedWorkspacesSettings.MemberSyncBatchSize
}
return model.ConnectedWorkspacesSettingsDefaultMemberSyncBatchSize
}
// Makes the remote channel to be read-only(announcement mode, only admins can create posts and reactions).
func (scs *Service) makeChannelReadOnly(channel *model.Channel) *model.AppError {
createPostPermission := model.ChannelModeratedPermissionsMap[model.PermissionCreatePost.Id]
createReactionPermission := model.ChannelModeratedPermissionsMap[model.PermissionAddReaction.Id]
updateMap := model.ChannelModeratedRolesPatch{
Guests: new(false),
Members: new(false),
}
readonlyChannelModerations := []*model.ChannelModerationPatch{
{
Name: &createPostPermission,
Roles: &updateMap,
},
{
Name: &createReactionPermission,
Roles: &updateMap,
},
}
_, err := scs.app.PatchChannelModerationsForChannel(request.EmptyContext(scs.server.Log()), channel, readonlyChannelModerations)
return err
}
// onConnectionStateChange is called whenever the connection state of a remote cluster changes,
// for example when one comes back online.
func (scs *Service) onConnectionStateChange(rc *model.RemoteCluster, online bool) {
if online {
// when a previously offline remote comes back online force a sync.
scs.SendPendingInvitesForRemote(rc)
scs.ForceSyncForRemote(rc)
scs.ForceMembershipSyncForRemote(rc)
// Schedule global user sync if feature is enabled
scs.scheduleGlobalUserSync(rc)
}
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Remote cluster connection status changed",
mlog.String("remote", rc.DisplayName),
mlog.String("remote_id", rc.RemoteId),
mlog.Bool("online", online),
)
}
func (scs *Service) notifyClientsForSharedChannelConverted(channel *model.Channel) {
scs.platform.InvalidateCacheForChannel(channel)
messageWs := model.NewWebSocketEvent(model.WebsocketEventChannelUpdated, "", channel.Id, "", nil, "")
channelJSON, err := json.Marshal(channel)
if err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "Cannot marshal channel to notify clients",
mlog.String("channel_id", channel.Id),
mlog.Err(err),
)
return
}
messageWs.Add("channel", string(channelJSON))
scs.app.Publish(messageWs)
}
func (scs *Service) notifyClientsForSharedChannelUpdate(channel *model.Channel) {
messageWs := model.NewWebSocketEvent(model.WebsocketEventChannelUpdated, channel.TeamId, "", "", nil, "")
messageWs.Add("channel_id", channel.Id)
scs.app.Publish(messageWs)
}
func remoteClusterDisplayName(rc *model.RemoteCluster) string {
if rc == nil {
return ""
}
if rc.DisplayName != "" {
return rc.DisplayName
}
if rc.Name != "" {
return rc.Name
}
return rc.RemoteId
}
// messageForSharedChannelStatePost returns the server-default-locale (i18n.T) copy for Post.Message so clients
// without a renderer still show the same sentence as the webapp (shared_channel.system_message.*).
func messageForSharedChannelStatePost(props model.StringInterface) string {
raw := props[model.PostPropsSharedChannelState]
state, _ := raw.(string)
switch state {
case model.SharedChannelStatePostValueShared:
wn, _ := props[model.PostPropsSharedChannelWorkspaceName].(string)
return i18n.T("shared_channel.system_message.now_shared", map[string]any{"WorkspaceName": wn})
case model.SharedChannelStatePostValueUnshared:
wn, _ := props[model.PostPropsSharedChannelWorkspaceName].(string)
if wn == "" {
return i18n.T("shared_channel.system_message.no_longer_shared_unknown")
}
return i18n.T("shared_channel.system_message.no_longer_shared", map[string]any{"WorkspaceName": wn})
default:
return i18n.T("shared_channel.system_message.no_longer_shared_unknown")
}
}
// postSharedChannelStatePost creates a system_shared_chan_state post. Message uses the system locale (i18n.T);
// capable clients still render from props in the viewer's language (not replicated via shared-channel post sync).
func (scs *Service) postSharedChannelStatePost(channel *model.Channel, props model.StringInterface) {
if channel == nil {
return
}
rctx := request.EmptyContext(scs.server.Log())
systemBot, botErr := scs.app.GetSystemBot(rctx)
if botErr != nil {
scs.server.Log().LogM(
mlog.MlvlSharedChannelServiceWarn,
"Could not get system bot for shared channel state post",
mlog.String("channel_id", channel.Id),
mlog.Err(botErr),
)
return
}
post := &model.Post{
UserId: systemBot.UserId,
ChannelId: channel.Id,
Message: messageForSharedChannelStatePost(props),
Type: model.PostTypeSharedChannelState,
Props: props,
}
_, _, appErr := scs.app.CreatePost(rctx, post, channel, model.CreatePostFlags{})
if appErr != nil {
scs.server.Log().LogM(
mlog.MlvlSharedChannelServiceWarn,
"Error creating shared channel state notification post",
mlog.String("channel_id", channel.Id),
mlog.Err(appErr),
)
}
}
func (scs *Service) postChannelSharedWithWorkspace(channel *model.Channel, peer *model.RemoteCluster) {
if channel == nil || peer == nil {
return
}
workspaceName := remoteClusterDisplayName(peer)
if workspaceName == "" {
workspaceName = peer.RemoteId
}
props := model.StringInterface{
model.PostPropsSharedChannelState: model.SharedChannelStatePostValueShared,
model.PostPropsSharedChannelWorkspaceName: workspaceName,
}
scs.postSharedChannelStatePost(channel, props)
}
func (scs *Service) postChannelUnsharedWithWorkspace(channel *model.Channel, workspaceName string) {
if channel == nil {
return
}
props := model.StringInterface{
model.PostPropsSharedChannelState: model.SharedChannelStatePostValueUnshared,
}
if workspaceName != "" {
props[model.PostPropsSharedChannelWorkspaceName] = workspaceName
}
scs.postSharedChannelStatePost(channel, props)
}
// IsRemoteClusterDirectlyConnected checks if a remote cluster has a direct connection to the current server
func (scs *Service) IsRemoteClusterDirectlyConnected(remoteId string) bool {
if remoteId == "" {
return true // Local server is always "directly connected"
}
// Check if the remote cluster exists and confirmed
rc, err := scs.server.GetStore().RemoteCluster().Get(remoteId, false)
if err != nil {
return false
}
isConfirmed := rc.IsConfirmed()
hasCreator := rc.CreatorId != ""
// For a direct connection, the remote cluster must be confirmed AND have a creator
// (someone on this server initiated or accepted the connection)
// Remote clusters known only through synthetic users won't have a creator
directConnection := isConfirmed && hasCreator
return directConnection
}
// OnReceiveSyncMessageForTesting is a wrapper to expose onReceiveSyncMessage for testing purposes
// isGlobalUserSyncEnabled checks if the global user sync feature is enabled
func (scs *Service) isGlobalUserSyncEnabled() bool {
cfg := scs.server.Config()
return cfg.FeatureFlags.EnableSyncAllUsersForRemoteCluster ||
(cfg.ConnectedWorkspacesSettings.SyncUsersOnConnectionOpen != nil && *cfg.ConnectedWorkspacesSettings.SyncUsersOnConnectionOpen)
}
// scheduleGlobalUserSync schedules a task to sync all users with a remote cluster
func (scs *Service) scheduleGlobalUserSync(rc *model.RemoteCluster) {
if !scs.isGlobalUserSyncEnabled() {
return
}
// Schedule the sync task
go func() {
// Create a special sync task with empty channelID
// This empty channelID is a deliberate marker for a global user sync task
task := newSyncTask("", "", rc.RemoteId, nil, nil)
task.schedule = time.Now().Add(NotifyMinimumDelay)
scs.addTask(task)
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Scheduled global user sync task for remote",
mlog.String("remote", rc.DisplayName),
mlog.String("remote_id", rc.RemoteId),
)
}()
}
// HasPendingTasksForTesting returns true if there are pending sync tasks in the queue
func (scs *Service) HasPendingTasksForTesting() bool {
scs.mux.RLock()
defer scs.mux.RUnlock()
return len(scs.tasks) > 0
}
// HandleSyncAllUsersForTesting exposes syncAllUsers for testing
func (scs *Service) HandleSyncAllUsersForTesting(rc *model.RemoteCluster) error {
return scs.syncAllUsers(rc)
}
// OnReceiveSyncMessageForTesting exposes onReceiveSyncMessage for testing
func (scs *Service) OnReceiveSyncMessageForTesting(msg model.RemoteClusterMsg, rc *model.RemoteCluster, response *remotecluster.Response) error {
return scs.onReceiveSyncMessage(msg, rc, response)
}
// HandleChannelNotSharedErrorForTesting is a wrapper to expose handleChannelNotSharedError for testing purposes
func (scs *Service) HandleChannelNotSharedErrorForTesting(msg *model.SyncMsg, rc *model.RemoteCluster) {
scs.handleChannelNotSharedError(msg, rc)
}
// TransformMentionsOnReceiveForTesting allows testing the full mention transformation flow
func (scs *Service) TransformMentionsOnReceiveForTesting(rctx request.CTX, post *model.Post, targetChannel *model.Channel, rc *model.RemoteCluster, mentionTransforms map[string]string) {
scs.transformMentionsOnReceive(rctx, post, targetChannel, rc, mentionTransforms)
}

View File

@@ -0,0 +1,377 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sharedchannel
import (
"errors"
"fmt"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
// ShareChannel marks a local channel as shared. If the channel is already shared this method has
// no effect and returns without error.
// TeamId, type, displayname, purpose, and header are fetched from the channel if not provided.
func (scs *Service) ShareChannel(sc *model.SharedChannel) (*model.SharedChannel, error) {
channel, err := scs.server.GetStore().Channel().Get(sc.ChannelId, true)
if err != nil {
return nil, fmt.Errorf("cannot fetch channel while sharing channel %s: %w", sc.ChannelId, err)
}
if !scs.server.Config().FeatureFlags.EnableSharedChannelsDMs && (channel.Type == model.ChannelTypeDirect || channel.Type == model.ChannelTypeGroup) {
return nil, errors.New("cannot share a direct or group channel")
}
// check if channel is already shared
scExisting, err := scs.server.GetStore().SharedChannel().Get(sc.ChannelId)
if err == nil {
// already shared, nothing to do
return scExisting, nil
}
if !isNotFoundError(err) {
return nil, fmt.Errorf("cannot check if channel %s is shared: %w", sc.ChannelId, err)
}
if sc.TeamId == "" {
sc.TeamId = channel.TeamId
}
if sc.Type == "" {
sc.Type = channel.Type
}
if sc.ShareName == "" {
sc.ShareName = channel.Name
}
if sc.ShareDisplayName == "" {
sc.ShareDisplayName = channel.DisplayName
}
if sc.SharePurpose == "" {
sc.SharePurpose = channel.Purpose
}
if sc.ShareHeader == "" {
sc.ShareHeader = channel.Header
}
if sc.CreatorId == "" {
sc.CreatorId = channel.CreatorId
}
// stores the SharedChannel and sets the share flag on the channel.
scNew, err := scs.server.GetStore().SharedChannel().Save(sc)
if err != nil {
return nil, err
}
// we get the channel to get the updated fields, including updateAt
// and Shared flag
channel, err = scs.server.GetStore().Channel().Get(sc.ChannelId, false)
if err != nil {
return nil, fmt.Errorf("cannot fetch channel after share: %w", err)
}
scs.notifyClientsForSharedChannelConverted(channel)
return scNew, nil
}
// UpdateSharedChannel updates the shared channel details such as displayname, purpose, or header
func (scs *Service) UpdateSharedChannel(sc *model.SharedChannel) (*model.SharedChannel, error) {
channel, err := scs.server.GetStore().Channel().Get(sc.ChannelId, true)
if err != nil {
return nil, err
}
scUpdated, err := scs.server.GetStore().SharedChannel().Update(sc)
if err != nil {
return nil, err
}
scs.notifyClientsForSharedChannelUpdate(channel)
return scUpdated, nil
}
// UnshareChannel unshares the channel by deleting the SharedChannels record and unsets the Channel `shared` flag.
// Returns true if a shared channel existed and was deleted.
func (scs *Service) UnshareChannel(channelID string) (bool, error) {
_, err := scs.server.GetStore().Channel().Get(channelID, true)
if err != nil {
return false, err
}
var workspaceNames []string
remotes, remotesErr := scs.server.GetStore().SharedChannel().GetRemotes(0, 10000, model.SharedChannelRemoteFilterOpts{ChannelId: channelID, IncludeUnconfirmed: true})
if remotesErr != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "Could not list remotes before unshare; skipping unshare system posts",
mlog.String("channel_id", channelID),
mlog.Err(remotesErr),
)
} else {
workspaceNames = make([]string, 0, len(remotes))
for _, scr := range remotes {
rc, rcErr := scs.server.GetStore().RemoteCluster().Get(scr.RemoteId, false)
workspaceName := scr.RemoteId
if rcErr == nil && rc != nil {
workspaceName = remoteClusterDisplayName(rc)
} else if rcErr != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "Could not load remote cluster while preparing unshare system post",
mlog.String("channel_id", channelID),
mlog.String("remote_id", scr.RemoteId),
mlog.Err(rcErr),
)
}
workspaceNames = append(workspaceNames, workspaceName)
}
}
// deletes the ShareChannel, unsets the share flag on the channel, deletes all records in SharedChannelRemotes for the channel.
deleted, err := scs.server.GetStore().SharedChannel().Delete(channelID)
if err != nil {
return false, err
}
// we get the channel to get the updated fields, including updateAt
// and Shared flag
channel, err := scs.server.GetStore().Channel().Get(channelID, false)
if err != nil {
return false, fmt.Errorf("cannot fetch channel after unshare: %w", err)
}
if deleted {
for _, workspaceName := range workspaceNames {
scs.postChannelUnsharedWithWorkspace(channel, workspaceName)
}
}
scs.notifyClientsForSharedChannelConverted(channel)
return deleted, nil
}
// InviteRemoteToChannel sends an invite to the remote to a shared channel. If `shareIfNotShared` is true
// then the channel is marked as `shared` first if needed.
func (scs *Service) InviteRemoteToChannel(channelID, remoteID, userID string, shareIfNotShared bool) error {
scStore := scs.server.GetStore().SharedChannel()
rcStore := scs.server.GetStore().RemoteCluster()
// check if remote already invited to channel
hasRemote, err := scStore.HasRemote(channelID, remoteID)
if err != nil {
return model.NewAppError("InviteRemoteToChannel", "api.command_share.fetch_remote.error",
map[string]any{"Error": err.Error()}, "", http.StatusInternalServerError)
}
if hasRemote {
// already invited, nothing to do
return nil
}
// set the channel `shared` flag if needed
if shareIfNotShared {
sc := &model.SharedChannel{
ChannelId: channelID,
CreatorId: userID,
Home: true,
RemoteId: "", // channel originates locally
}
if _, err = scs.ShareChannel(sc); err != nil {
return model.NewAppError("InviteRemoteToChannel", "api.command_share.share_channel.error",
map[string]any{"Error": err.Error()}, "", http.StatusBadRequest)
}
} else {
if err = scs.CheckChannelIsShared(channelID); err != nil {
return model.NewAppError("InviteRemoteToChannel", "api.command_share.channel_not_shared.error",
map[string]any{"ChannelID": channelID}, "", http.StatusBadRequest)
}
}
rc, err := rcStore.Get(remoteID, false)
if err != nil {
return model.NewAppError("InviteRemoteToChannel", "api.command_share.remote_id_invalid.error",
map[string]any{"Error": err.Error()}, "", http.StatusInternalServerError).Wrap(err)
}
// don't allow invitation to shared channel originating from remote.
// (also blocks cyclic invitations)
if err = scs.CheckCanInviteToSharedChannel(channelID); err != nil {
if errors.Is(err, model.ErrChannelHomedOnRemote) {
return model.NewAppError("InviteRemoteToChannel", "api.command_share.channel_invite_not_home.error", nil, "", http.StatusBadRequest)
}
scs.server.Log().Debug("InviteRemoteToChannel failed to check if can-invite",
mlog.String("name", rc.Name),
mlog.String("channel_id", channelID),
mlog.Err(err),
)
return model.NewAppError("InviteRemoteToChannel", "api.command_share.channel_invite.error",
map[string]any{"Name": rc.DisplayName, "Error": err.Error()}, "CheckCanInviteToSharedChannel", http.StatusInternalServerError).Wrap(err)
}
channel, err := scs.server.GetStore().Channel().Get(channelID, true)
if err != nil {
return model.NewAppError("InviteRemoteToChannel", "api.command_share.channel_invite.error",
map[string]any{"Name": rc.DisplayName, "Error": err.Error()}, "", http.StatusInternalServerError).Wrap(err)
}
// send channel invite to remote cluster. Will notify clients of channel change.
if err := scs.SendChannelInvite(channel, userID, rc); err != nil {
return model.NewAppError("InviteRemoteToChannel", "api.command_share.channel_invite.error",
map[string]any{"Name": rc.DisplayName, "Error": err.Error()}, "", http.StatusInternalServerError).Wrap(err)
}
return nil
}
// unshareChannelIfNoActiveRemotes checks if there are any remaining
// non-deleted remotes for the channel and unshares the channel if
// there are none. Returns true if the channel was unshared.
func (scs *Service) unshareChannelIfNoActiveRemotes(channelID string) (bool, error) {
opts := model.SharedChannelRemoteFilterOpts{ChannelId: channelID, IncludeUnconfirmed: true}
remotes, err := scs.server.GetStore().SharedChannel().GetRemotes(0, 1, opts)
if err != nil {
return false, fmt.Errorf("failed to check remaining remotes: %w", err)
}
// If no remotes remain, unshare the channel
if len(remotes) == 0 {
unshared, err := scs.UnshareChannel(channelID)
if err != nil {
return false, fmt.Errorf("failed to automatically unshare channel after removing last remote: %w", err)
}
return unshared, nil
}
return false, nil
}
func (scs *Service) UninviteRemoteFromChannel(channelID, remoteID string) error {
scr, err := scs.server.GetStore().SharedChannel().GetRemoteByIds(channelID, remoteID)
if err != nil || scr.ChannelId != channelID || scr.DeleteAt != 0 {
return model.NewAppError("UninviteRemoteFromChannel", "api.command_share.channel_remote_id_not_exists",
map[string]any{"RemoteId": remoteID}, "", http.StatusInternalServerError)
}
deleted, err := scs.server.GetStore().SharedChannel().DeleteRemote(scr.Id)
if err != nil || !deleted {
code := http.StatusInternalServerError
if err == nil {
err = errors.New("not found")
code = http.StatusBadRequest
}
return model.NewAppError("UninviteRemoteFromChannel", "api.command_share.could_not_uninvite.error",
map[string]any{"RemoteId": remoteID, "Error": err.Error()}, "", code)
}
channel, chErr := scs.server.GetStore().Channel().Get(channelID, true)
if chErr != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "Could not load channel after uninvite for system post",
mlog.String("channel_id", channelID),
mlog.String("remote_id", remoteID),
mlog.Err(chErr),
)
} else {
rc, rcErr := scs.server.GetStore().RemoteCluster().Get(remoteID, false)
workspaceName := remoteID
if rcErr == nil && rc != nil {
workspaceName = remoteClusterDisplayName(rc)
} else if rcErr != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "Could not load remote cluster after uninvite for system post",
mlog.String("channel_id", channelID),
mlog.String("remote_id", remoteID),
mlog.Err(rcErr),
)
}
scs.postChannelUnsharedWithWorkspace(channel, workspaceName)
}
_, unshareErr := scs.unshareChannelIfNoActiveRemotes(channelID)
if unshareErr != nil {
// We don't want to fail the uninvite operation if the unshare fails
scs.server.Log().Error("Error during automatic unshare after uninvite",
mlog.String("channel_id", channelID),
mlog.Err(unshareErr),
)
}
return nil
}
// CheckChannelNotShared returns nil only if the channel is not already shared. Otherwise ErrChannelAlreadyShared is
// returned if the channel is shared, or database error.
func (scs *Service) CheckChannelNotShared(channelID string) error {
// check that channel exists.
if _, err := scs.server.GetStore().Channel().Get(channelID, true); err != nil {
return fmt.Errorf("cannot find channel %s: %w", channelID, err)
}
// Check channel is not already shared.
if _, err := scs.server.GetStore().SharedChannel().Get(channelID); err == nil {
return model.ErrChannelAlreadyShared
}
return nil
}
// CheckChannelIsShared returns nil only if the channel is shared. Otherwise a store.ErrNotFound is returned
// or database error.
func (scs *Service) CheckChannelIsShared(channelID string) error {
if _, err := scs.server.GetStore().SharedChannel().Get(channelID); err != nil {
var errNotFound *store.ErrNotFound
if errors.As(err, &errNotFound) {
return fmt.Errorf("%w: %v", model.ErrChannelNotShared, errNotFound)
}
return fmt.Errorf("cannot check if channel %s is shared: %w", channelID, err)
}
return nil
}
// CheckCanInviteToSharedChannel checks if an invitation can be sent for the specified channel.
// - don't allow invitations to a shared channel originating from remote.
// - block cyclic invitations
// - the channel must exist
func (scs *Service) CheckCanInviteToSharedChannel(channelId string) error {
sc, err := scs.server.GetStore().SharedChannel().Get(channelId)
if err != nil {
if isNotFoundError(err) {
return fmt.Errorf("%w: %v", model.ErrChannelNotShared, err)
}
return fmt.Errorf("cannot find channel: %w", err)
}
if !sc.Home {
return model.ErrChannelHomedOnRemote
}
return nil
}
// updateMembershipSyncCursor updates the LastMembersSyncAt value for the shared channel remote
// This provides centralized and consistent cursor management
func (scs *Service) updateMembershipSyncCursor(channelID string, remoteID string, newTimestamp int64) error {
// Get the remote record
scr, err := scs.server.GetStore().SharedChannel().GetRemoteByIds(channelID, remoteID)
if err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "Failed to get shared channel remote for cursor update",
mlog.String("channel_id", channelID),
mlog.String("remote_id", remoteID),
mlog.Int("timestamp", int(newTimestamp)),
mlog.Err(err),
)
return fmt.Errorf("failed to get shared channel remote for cursor update: %w", err)
}
if scr == nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "Shared channel remote not found for cursor update",
mlog.String("channel_id", channelID),
mlog.String("remote_id", remoteID),
)
return fmt.Errorf("shared channel remote not found for channel %s and remote %s", channelID, remoteID)
}
// Update the cursor - the store will handle ensuring it only moves forward
err = scs.server.GetStore().SharedChannel().UpdateRemoteMembershipCursor(scr.Id, newTimestamp)
if err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "Failed to update membership cursor",
mlog.String("channel_id", channelID),
mlog.String("remote_id", remoteID),
mlog.String("remote_record_id", scr.Id),
mlog.Int("timestamp", int(newTimestamp)),
mlog.Err(err),
)
}
return err
}

View File

@@ -0,0 +1,324 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sharedchannel
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
)
// testNoopPlatform satisfies PlatformIface for API tests that trigger websocket/cache notifications.
type testNoopPlatform struct{}
func (testNoopPlatform) InvalidateCacheForUser(userID string) {}
func (testNoopPlatform) InvalidateCacheForChannel(channel *model.Channel) {}
func TestUnshareChannel_systemPostsForEachRemoteWorkspace(t *testing.T) {
channelID := model.NewId()
teamID := model.NewId()
channel := &model.Channel{Id: channelID, TeamId: teamID, Name: "town-square", DisplayName: "Town Square"}
remoteIDA := model.NewId()
remoteIDB := model.NewId()
remotes := []*model.SharedChannelRemote{
{Id: model.NewId(), ChannelId: channelID, RemoteId: remoteIDA, DeleteAt: 0},
{Id: model.NewId(), ChannelId: channelID, RemoteId: remoteIDB, DeleteAt: 0},
}
rcA := &model.RemoteCluster{RemoteId: remoteIDA, DisplayName: "Workspace A"}
rcB := &model.RemoteCluster{RemoteId: remoteIDB, Name: "internal-b"}
mockServer := &MockServerIface{}
logger := mlog.CreateConsoleTestLogger(t)
mockServer.On("Log").Return(logger)
mockStore := &mocks.Store{}
mockChannelStore := mocks.ChannelStore{}
mockSharedChannelStore := mocks.SharedChannelStore{}
mockRemoteClusterStore := mocks.RemoteClusterStore{}
mockServer.On("GetStore").Return(mockStore)
mockStore.On("Channel").Return(&mockChannelStore)
mockStore.On("SharedChannel").Return(&mockSharedChannelStore)
mockStore.On("RemoteCluster").Return(&mockRemoteClusterStore)
mockChannelStore.On("Get", channelID, true).Return(channel, nil).Once()
mockChannelStore.On("Get", channelID, false).Return(channel, nil).Once()
mockSharedChannelStore.On("GetRemotes", 0, 10000, model.SharedChannelRemoteFilterOpts{
ChannelId: channelID,
IncludeUnconfirmed: true,
}).Return(remotes, nil).Once()
mockSharedChannelStore.On("Delete", channelID).Return(true, nil).Once()
mockRemoteClusterStore.On("Get", remoteIDA, false).Return(rcA, nil).Once()
mockRemoteClusterStore.On("Get", remoteIDB, false).Return(rcB, nil).Once()
mockApp := &MockAppIface{}
bot := &model.Bot{UserId: model.NewId()}
var postedWorkspaces []string
mockApp.On("GetSystemBot", mock.Anything).Return(bot, (*model.AppError)(nil))
mockApp.On("Publish", mock.Anything).Return()
mockApp.On("CreatePost", mock.Anything, mock.AnythingOfType("*model.Post"), mock.AnythingOfType("*model.Channel"), mock.Anything).
Run(func(args mock.Arguments) {
post := args.Get(1).(*model.Post)
wn, _ := post.GetProps()[model.PostPropsSharedChannelWorkspaceName].(string)
assert.Equal(t, model.SharedChannelStatePostValueUnshared, post.GetProps()[model.PostPropsSharedChannelState])
postedWorkspaces = append(postedWorkspaces, wn)
assert.Equal(t,
i18n.T("shared_channel.system_message.no_longer_shared", map[string]any{"WorkspaceName": wn}),
post.Message,
)
}).Return(&model.Post{}, false, (*model.AppError)(nil))
scs := &Service{
server: mockServer,
platform: testNoopPlatform{},
app: mockApp,
}
deleted, err := scs.UnshareChannel(channelID)
require.NoError(t, err)
assert.True(t, deleted)
mockApp.AssertNumberOfCalls(t, "CreatePost", 2)
assert.ElementsMatch(t, []string{"Workspace A", "internal-b"}, postedWorkspaces)
mockApp.AssertExpectations(t)
mockSharedChannelStore.AssertExpectations(t)
mockRemoteClusterStore.AssertExpectations(t)
}
func TestUnshareChannel_whenListRemotesFails_stillUnsharesWithoutSystemPosts(t *testing.T) {
channelID := model.NewId()
channel := &model.Channel{Id: channelID, TeamId: model.NewId()}
mockServer := &MockServerIface{}
logger := mlog.CreateConsoleTestLogger(t)
mockServer.On("Log").Return(logger)
mockStore := &mocks.Store{}
mockChannelStore := mocks.ChannelStore{}
mockSharedChannelStore := mocks.SharedChannelStore{}
mockServer.On("GetStore").Return(mockStore)
mockStore.On("Channel").Return(&mockChannelStore)
mockStore.On("SharedChannel").Return(&mockSharedChannelStore)
mockChannelStore.On("Get", channelID, true).Return(channel, nil).Once()
mockChannelStore.On("Get", channelID, false).Return(channel, nil).Once()
listErr := errors.New("store remotes unavailable")
mockSharedChannelStore.On("GetRemotes", 0, 10000, model.SharedChannelRemoteFilterOpts{
ChannelId: channelID,
IncludeUnconfirmed: true,
}).Return(([]*model.SharedChannelRemote)(nil), listErr).Once()
mockSharedChannelStore.On("Delete", channelID).Return(true, nil).Once()
mockApp := &MockAppIface{}
mockApp.On("Publish", mock.Anything).Return()
scs := &Service{
server: mockServer,
platform: testNoopPlatform{},
app: mockApp,
}
deleted, err := scs.UnshareChannel(channelID)
require.NoError(t, err)
assert.True(t, deleted)
mockApp.AssertNotCalled(t, "CreatePost")
mockApp.AssertExpectations(t)
}
func TestUnshareChannel_whenRemoteClusterMissingUsesRemoteIdInPost(t *testing.T) {
channelID := model.NewId()
channel := &model.Channel{Id: channelID, TeamId: model.NewId()}
remoteID := model.NewId()
remotes := []*model.SharedChannelRemote{
{Id: model.NewId(), ChannelId: channelID, RemoteId: remoteID, DeleteAt: 0},
}
mockServer := &MockServerIface{}
logger := mlog.CreateConsoleTestLogger(t)
mockServer.On("Log").Return(logger)
mockStore := &mocks.Store{}
mockChannelStore := mocks.ChannelStore{}
mockSharedChannelStore := mocks.SharedChannelStore{}
mockRemoteClusterStore := mocks.RemoteClusterStore{}
mockServer.On("GetStore").Return(mockStore)
mockStore.On("Channel").Return(&mockChannelStore)
mockStore.On("SharedChannel").Return(&mockSharedChannelStore)
mockStore.On("RemoteCluster").Return(&mockRemoteClusterStore)
mockChannelStore.On("Get", channelID, true).Return(channel, nil).Once()
mockChannelStore.On("Get", channelID, false).Return(channel, nil).Once()
mockSharedChannelStore.On("GetRemotes", 0, 10000, model.SharedChannelRemoteFilterOpts{
ChannelId: channelID,
IncludeUnconfirmed: true,
}).Return(remotes, nil).Once()
mockSharedChannelStore.On("Delete", channelID).Return(true, nil).Once()
mockRemoteClusterStore.On("Get", remoteID, false).Return((*model.RemoteCluster)(nil), errors.New("not found")).Once()
mockApp := &MockAppIface{}
bot := &model.Bot{UserId: model.NewId()}
mockApp.On("GetSystemBot", mock.Anything).Return(bot, (*model.AppError)(nil))
mockApp.On("Publish", mock.Anything).Return()
mockApp.On("CreatePost", mock.Anything, mock.AnythingOfType("*model.Post"), mock.AnythingOfType("*model.Channel"), mock.Anything).
Run(func(args mock.Arguments) {
post := args.Get(1).(*model.Post)
assert.Equal(t, remoteID, post.GetProps()[model.PostPropsSharedChannelWorkspaceName])
}).Return(&model.Post{}, false, (*model.AppError)(nil))
scs := &Service{
server: mockServer,
platform: testNoopPlatform{},
app: mockApp,
}
_, err := scs.UnshareChannel(channelID)
require.NoError(t, err)
mockApp.AssertNumberOfCalls(t, "CreatePost", 1)
}
func TestUninviteRemoteFromChannel_postsUnsharedSystemMessage(t *testing.T) {
channelID := model.NewId()
remoteID := model.NewId()
scrID := model.NewId()
channel := &model.Channel{Id: channelID, TeamId: model.NewId()}
scr := &model.SharedChannelRemote{
Id: scrID,
ChannelId: channelID,
RemoteId: remoteID,
DeleteAt: 0,
}
rc := &model.RemoteCluster{RemoteId: remoteID, DisplayName: "Peer workspace"}
remaining := []*model.SharedChannelRemote{{Id: model.NewId(), ChannelId: channelID, RemoteId: model.NewId(), DeleteAt: 0}}
mockServer := &MockServerIface{}
logger := mlog.CreateConsoleTestLogger(t)
mockServer.On("Log").Return(logger)
setupMockServerWithConfig(mockServer)
mockStore := &mocks.Store{}
mockChannelStore := mocks.ChannelStore{}
mockSharedChannelStore := mocks.SharedChannelStore{}
mockRemoteClusterStore := mocks.RemoteClusterStore{}
mockServer.On("GetStore").Return(mockStore)
mockStore.On("Channel").Return(&mockChannelStore)
mockStore.On("SharedChannel").Return(&mockSharedChannelStore)
mockStore.On("RemoteCluster").Return(&mockRemoteClusterStore)
mockSharedChannelStore.On("GetRemoteByIds", channelID, remoteID).Return(scr, nil).Once()
mockSharedChannelStore.On("DeleteRemote", scrID).Return(true, nil).Once()
mockChannelStore.On("Get", channelID, true).Return(channel, nil).Once()
mockRemoteClusterStore.On("Get", remoteID, false).Return(rc, nil).Once()
mockSharedChannelStore.On("GetRemotes", 0, 1, model.SharedChannelRemoteFilterOpts{
ChannelId: channelID,
IncludeUnconfirmed: true,
}).Return(remaining, nil).Once()
mockApp := &MockAppIface{}
bot := &model.Bot{UserId: model.NewId()}
mockApp.On("GetSystemBot", mock.Anything).Return(bot, (*model.AppError)(nil))
mockApp.On("CreatePost", mock.Anything, mock.AnythingOfType("*model.Post"), mock.AnythingOfType("*model.Channel"), mock.Anything).
Run(func(args mock.Arguments) {
post := args.Get(1).(*model.Post)
assert.Equal(t, "Peer workspace", post.GetProps()[model.PostPropsSharedChannelWorkspaceName])
assert.Equal(t, model.SharedChannelStatePostValueUnshared, post.GetProps()[model.PostPropsSharedChannelState])
}).Return(&model.Post{}, false, (*model.AppError)(nil))
scs := &Service{
server: mockServer,
platform: testNoopPlatform{},
app: mockApp,
}
err := scs.UninviteRemoteFromChannel(channelID, remoteID)
require.NoError(t, err)
mockApp.AssertNumberOfCalls(t, "CreatePost", 1)
mockSharedChannelStore.AssertNotCalled(t, "Delete", mock.Anything)
mockApp.AssertExpectations(t)
mockRemoteClusterStore.AssertExpectations(t)
}
func TestUninviteRemoteFromChannel_whenLastRemoteUnsharesChannel(t *testing.T) {
channelID := model.NewId()
remoteID := model.NewId()
scrID := model.NewId()
channel := &model.Channel{Id: channelID, TeamId: model.NewId()}
scr := &model.SharedChannelRemote{
Id: scrID,
ChannelId: channelID,
RemoteId: remoteID,
DeleteAt: 0,
}
rc := &model.RemoteCluster{RemoteId: remoteID, DisplayName: "Last peer"}
mockServer := &MockServerIface{}
logger := mlog.CreateConsoleTestLogger(t)
mockServer.On("Log").Return(logger)
setupMockServerWithConfig(mockServer)
mockStore := &mocks.Store{}
mockChannelStore := mocks.ChannelStore{}
mockSharedChannelStore := mocks.SharedChannelStore{}
mockRemoteClusterStore := mocks.RemoteClusterStore{}
mockServer.On("GetStore").Return(mockStore)
mockStore.On("Channel").Return(&mockChannelStore)
mockStore.On("SharedChannel").Return(&mockSharedChannelStore)
mockStore.On("RemoteCluster").Return(&mockRemoteClusterStore)
mockSharedChannelStore.On("GetRemoteByIds", channelID, remoteID).Return(scr, nil).Once()
mockSharedChannelStore.On("DeleteRemote", scrID).Return(true, nil).Once()
// Channel load for uninvite system post, then UnshareChannel initial get, then UnshareChannel post-delete get.
mockChannelStore.On("Get", channelID, true).Return(channel, nil).Times(2)
mockChannelStore.On("Get", channelID, false).Return(channel, nil).Once()
mockRemoteClusterStore.On("Get", remoteID, false).Return(rc, nil).Once()
mockSharedChannelStore.On("GetRemotes", 0, 1, model.SharedChannelRemoteFilterOpts{
ChannelId: channelID,
IncludeUnconfirmed: true,
}).Return([]*model.SharedChannelRemote{}, nil).Once()
mockSharedChannelStore.On("GetRemotes", 0, 10000, model.SharedChannelRemoteFilterOpts{
ChannelId: channelID,
IncludeUnconfirmed: true,
}).Return([]*model.SharedChannelRemote{}, nil).Once()
mockSharedChannelStore.On("Delete", channelID).Return(true, nil).Once()
mockApp := &MockAppIface{}
bot := &model.Bot{UserId: model.NewId()}
mockApp.On("GetSystemBot", mock.Anything).Return(bot, (*model.AppError)(nil))
mockApp.On("Publish", mock.Anything).Return()
mockApp.On("CreatePost", mock.Anything, mock.AnythingOfType("*model.Post"), mock.AnythingOfType("*model.Channel"), mock.Anything).
Return(&model.Post{}, false, (*model.AppError)(nil))
scs := &Service{
server: mockServer,
platform: testNoopPlatform{},
app: mockApp,
}
err := scs.UninviteRemoteFromChannel(channelID, remoteID)
require.NoError(t, err)
// One system post from UninviteRemoteFromChannel; UnshareChannel sees no remotes so it does not add more.
mockApp.AssertNumberOfCalls(t, "CreatePost", 1)
mockSharedChannelStore.AssertCalled(t, "Delete", channelID)
mockRemoteClusterStore.AssertExpectations(t)
}

View File

@@ -0,0 +1,118 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sharedchannel
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func TestRemoteClusterDisplayName(t *testing.T) {
t.Run("nil remote", func(t *testing.T) {
assert.Equal(t, "", remoteClusterDisplayName(nil))
})
t.Run("only RemoteId set", func(t *testing.T) {
rc := &model.RemoteCluster{RemoteId: "rid-abc"}
assert.Equal(t, "rid-abc", remoteClusterDisplayName(rc))
})
t.Run("Name used when DisplayName empty", func(t *testing.T) {
rc := &model.RemoteCluster{RemoteId: "rid-abc", Name: "internal-name"}
assert.Equal(t, "internal-name", remoteClusterDisplayName(rc))
})
t.Run("DisplayName preferred over Name and RemoteId", func(t *testing.T) {
rc := &model.RemoteCluster{
RemoteId: "rid-abc",
Name: "internal-name",
DisplayName: "Pretty Workspace",
}
assert.Equal(t, "Pretty Workspace", remoteClusterDisplayName(rc))
})
}
func TestMessageForSharedChannelStatePost(t *testing.T) {
t.Run("shared", func(t *testing.T) {
props := model.StringInterface{
model.PostPropsSharedChannelState: model.SharedChannelStatePostValueShared,
model.PostPropsSharedChannelWorkspaceName: "Acme",
}
got := messageForSharedChannelStatePost(props)
assert.Equal(t, i18n.T("shared_channel.system_message.now_shared", map[string]any{"WorkspaceName": "Acme"}), got)
})
t.Run("unshared with known workspace", func(t *testing.T) {
props := model.StringInterface{
model.PostPropsSharedChannelState: model.SharedChannelStatePostValueUnshared,
model.PostPropsSharedChannelWorkspaceName: "Acme",
}
got := messageForSharedChannelStatePost(props)
assert.Equal(t, i18n.T("shared_channel.system_message.no_longer_shared", map[string]any{"WorkspaceName": "Acme"}), got)
})
t.Run("unshared without workspace name", func(t *testing.T) {
props := model.StringInterface{
model.PostPropsSharedChannelState: model.SharedChannelStatePostValueUnshared,
}
got := messageForSharedChannelStatePost(props)
assert.Equal(t, i18n.T("shared_channel.system_message.no_longer_shared_unknown"), got)
})
t.Run("default empty or unknown state uses unknown unshared copy", func(t *testing.T) {
assert.Equal(t,
i18n.T("shared_channel.system_message.no_longer_shared_unknown"),
messageForSharedChannelStatePost(model.StringInterface{}),
)
assert.Equal(t,
i18n.T("shared_channel.system_message.no_longer_shared_unknown"),
messageForSharedChannelStatePost(model.StringInterface{
model.PostPropsSharedChannelState: "unexpected",
}),
)
})
}
func TestPostChannelUnsharedWithWorkspace_emptyWorkspaceName(t *testing.T) {
mockServer := &MockServerIface{}
logger := mlog.CreateConsoleTestLogger(t)
mockServer.On("Log").Return(logger)
mockApp := &MockAppIface{}
scs := &Service{
server: mockServer,
app: mockApp,
}
bot := &model.Bot{UserId: model.NewId()}
mockApp.On("GetSystemBot", mock.Anything).Return(bot, (*model.AppError)(nil))
mockApp.On("CreatePost", mock.Anything, mock.AnythingOfType("*model.Post"), mock.AnythingOfType("*model.Channel"), mock.Anything).
Run(func(args mock.Arguments) {
post := args.Get(1).(*model.Post)
ch := args.Get(2).(*model.Channel)
assert.Equal(t, model.PostTypeSharedChannelState, post.Type)
assert.Equal(t, bot.UserId, post.UserId)
assert.Equal(t, ch.Id, post.ChannelId)
assert.Equal(t, model.SharedChannelStatePostValueUnshared, post.GetProps()[model.PostPropsSharedChannelState])
_, hasWorkspaceName := post.GetProps()[model.PostPropsSharedChannelWorkspaceName]
assert.False(t, hasWorkspaceName)
assert.Equal(t,
i18n.T("shared_channel.system_message.no_longer_shared_unknown"),
post.Message,
)
}).Return(&model.Post{}, false, (*model.AppError)(nil))
channel := &model.Channel{Id: model.NewId(), TeamId: model.NewId()}
scs.postChannelUnsharedWithWorkspace(channel, "")
mockApp.AssertExpectations(t)
}

View File

@@ -0,0 +1,854 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sharedchannel
import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/platform/services/remotecluster"
)
var (
ErrRemoteIDMismatch = errors.New("remoteID mismatch")
ErrChannelIDMismatch = errors.New("channelID mismatch")
ErrUserDMPermission = errors.New("users cannot DM each other")
ErrChannelNotShared = errors.New("channel is no longer shared")
)
func (scs *Service) onReceiveSyncMessage(msg model.RemoteClusterMsg, rc *model.RemoteCluster, response *remotecluster.Response) error {
if msg.Topic != TopicSync && msg.Topic != TopicChannelMembership && msg.Topic != TopicGlobalUserSync {
return fmt.Errorf("wrong topic, expected sync-related topic, got `%s`", msg.Topic)
}
if len(msg.Payload) == 0 {
return errors.New("empty sync message")
}
if scs.server.Log().IsLevelEnabled(mlog.LvlSharedChannelServiceMessagesInbound) {
scs.server.Log().Log(mlog.LvlSharedChannelServiceMessagesInbound, "inbound message",
mlog.String("remote", rc.DisplayName),
mlog.String("msg", msg.Payload),
)
}
var sm model.SyncMsg
if err := json.Unmarshal(msg.Payload, &sm); err != nil {
return fmt.Errorf("invalid sync message: %w", err)
}
return scs.processSyncMessageForRemote(request.EmptyContext(scs.server.Log()), &sm, rc, response)
}
// processGlobalUserSync processes a global user sync message from a remote cluster or plugin.
// Returns the SyncResponse directly.
func (scs *Service) processGlobalUserSync(rctx request.CTX, syncMsg *model.SyncMsg, rc *model.RemoteCluster) (model.SyncResponse, error) {
syncResp := model.SyncResponse{
UserErrors: make([]string, 0),
UsersSyncd: make([]string, 0),
}
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Processing global user sync",
mlog.String("remote", rc.Name),
mlog.Int("user_count", len(syncMsg.Users)),
)
// Process all users in the sync message
for _, user := range syncMsg.Users {
if userSaved, err := scs.upsertSyncUser(rctx, user, nil, rc); err != nil {
syncResp.UserErrors = append(syncResp.UserErrors, user.Id)
} else {
syncResp.UsersSyncd = append(syncResp.UsersSyncd, userSaved.Id)
if syncResp.UsersLastUpdateAt < user.UpdateAt {
syncResp.UsersLastUpdateAt = user.UpdateAt
}
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Global user upserted via sync",
mlog.String("remote", rc.Name),
mlog.String("user_id", user.Id),
)
}
}
return syncResp, nil
}
// ProcessSyncMessage processes an inbound sync message from a remote cluster or plugin.
// Returns the SyncResponse directly.
func (scs *Service) ProcessSyncMessage(rctx request.CTX, syncMsg *model.SyncMsg, rc *model.RemoteCluster) (model.SyncResponse, error) {
var targetChannel *model.Channel
var team *model.Team
var err error
syncResp := model.SyncResponse{
UserErrors: make([]string, 0),
UsersSyncd: make([]string, 0),
PostErrors: make([]string, 0),
ReactionErrors: make([]string, 0),
AcknowledgementErrors: make([]string, 0),
MembershipErrors: make([]string, 0),
}
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Sync msg received",
mlog.String("remote", rc.Name),
mlog.String("channel_id", syncMsg.ChannelId),
mlog.Int("user_count", len(syncMsg.Users)),
mlog.Int("post_count", len(syncMsg.Posts)),
mlog.Int("reaction_count", len(syncMsg.Reactions)),
mlog.Int("acknowledgement_count", len(syncMsg.Acknowledgements)),
mlog.Int("status_count", len(syncMsg.Statuses)),
mlog.Int("membership_change_count", len(syncMsg.MembershipChanges)),
)
// Check if this is a global user sync message (no channel ID and only users)
if syncMsg.ChannelId == "" {
if len(syncMsg.Posts) != 0 ||
len(syncMsg.Reactions) != 0 ||
len(syncMsg.Statuses) != 0 {
return syncResp, fmt.Errorf("global user sync message should not contain posts, reactions or statuses")
}
if len(syncMsg.Users) == 0 {
return syncResp, nil
}
// Check if feature flag is enabled
if !scs.isGlobalUserSyncEnabled() {
return syncResp, nil
}
return scs.processGlobalUserSync(rctx, syncMsg, rc)
}
// For regular sync messages, we need a specific channel
if targetChannel, err = scs.server.GetStore().Channel().Get(syncMsg.ChannelId, true); err != nil {
// if the channel doesn't exist then none of these sync items are going to work.
return syncResp, fmt.Errorf("channel not found processing sync message: %w", err)
}
// make sure target channel is shared with the remote
exists, err := scs.server.GetStore().SharedChannel().HasRemote(targetChannel.Id, rc.RemoteId)
if err != nil {
return syncResp, fmt.Errorf("cannot check channel share state for sync message: %w", err)
}
if !exists {
return syncResp, fmt.Errorf("cannot process sync message; %w: %s",
ErrChannelNotShared, syncMsg.ChannelId)
}
// add/update users before posts
for _, user := range syncMsg.Users {
if userSaved, err := scs.upsertSyncUser(rctx, user, targetChannel, rc); err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "Error upserting sync user",
mlog.String("remote", rc.Name),
mlog.String("channel_id", syncMsg.ChannelId),
mlog.String("user_id", user.Id),
mlog.Err(err))
} else {
syncResp.UsersSyncd = append(syncResp.UsersSyncd, userSaved.Id)
if syncResp.UsersLastUpdateAt < user.UpdateAt {
syncResp.UsersLastUpdateAt = user.UpdateAt
}
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "User upserted via sync",
mlog.String("remote", rc.Name),
mlog.String("channel_id", syncMsg.ChannelId),
mlog.String("user_id", user.Id),
)
}
}
for _, post := range syncMsg.Posts {
if post.Type == model.PostTypeCard {
continue
}
if syncMsg.ChannelId != post.ChannelId {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "ChannelId mismatch",
mlog.String("remote", rc.Name),
mlog.String("sm_channel_id", syncMsg.ChannelId),
mlog.String("sm_post_channel_id", post.ChannelId),
mlog.String("post_id", post.Id),
)
syncResp.PostErrors = append(syncResp.PostErrors, post.Id)
continue
}
if (targetChannel.Type != model.ChannelTypeDirect && targetChannel.Type != model.ChannelTypeGroup) && team == nil {
var err2 error
team, err2 = scs.server.GetStore().Channel().GetTeamForChannel(syncMsg.ChannelId)
if err2 != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "Error getting Team for Channel",
mlog.String("channel_id", post.ChannelId),
mlog.String("post_id", post.Id),
mlog.String("remote", rc.Name),
mlog.Err(err2),
)
syncResp.PostErrors = append(syncResp.PostErrors, post.Id)
continue
}
}
// process perma-links for remote
if team != nil {
post.Message = scs.processPermalinkFromRemote(post, team)
}
// add/update post
rpost, err := scs.upsertSyncPost(post, targetChannel, rc, syncMsg.MentionTransforms)
if err != nil {
syncResp.PostErrors = append(syncResp.PostErrors, post.Id)
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "Error upserting sync post",
mlog.String("post_id", post.Id),
mlog.String("channel_id", post.ChannelId),
mlog.String("remote", rc.Name),
mlog.Err(err),
)
} else if syncResp.PostsLastUpdateAt < rpost.UpdateAt {
syncResp.PostsLastUpdateAt = rpost.UpdateAt
}
}
// add/remove reactions
for _, reaction := range syncMsg.Reactions {
if _, err := scs.upsertSyncReaction(reaction, targetChannel, rc); err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "Error upserting sync reaction",
mlog.String("remote", rc.Name),
mlog.String("user_id", reaction.UserId),
mlog.String("post_id", reaction.PostId),
mlog.String("emoji", reaction.EmojiName),
mlog.Int("delete_at", reaction.DeleteAt),
mlog.Err(err),
)
} else {
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Reaction upserted via sync",
mlog.String("remote", rc.Name),
mlog.String("user_id", reaction.UserId),
mlog.String("post_id", reaction.PostId),
mlog.String("emoji", reaction.EmojiName),
mlog.Int("delete_at", reaction.DeleteAt),
)
if syncResp.ReactionsLastUpdateAt < reaction.UpdateAt {
syncResp.ReactionsLastUpdateAt = reaction.UpdateAt
}
}
}
// add/remove acknowledgements
for _, acknowledgement := range syncMsg.Acknowledgements {
if _, err := scs.upsertSyncAcknowledgement(acknowledgement, targetChannel, rc); err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "Error upserting sync acknowledgement",
mlog.String("remote", rc.Name),
mlog.String("user_id", acknowledgement.UserId),
mlog.String("post_id", acknowledgement.PostId),
mlog.Int("acknowledged_at", acknowledgement.AcknowledgedAt),
mlog.Err(err),
)
syncResp.AcknowledgementErrors = append(syncResp.AcknowledgementErrors, acknowledgement.PostId)
} else {
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Acknowledgement upserted via sync",
mlog.String("remote", rc.Name),
mlog.String("user_id", acknowledgement.UserId),
mlog.String("post_id", acknowledgement.PostId),
mlog.Int("acknowledged_at", acknowledgement.AcknowledgedAt),
)
if syncResp.AcknowledgementsLastUpdateAt < acknowledgement.AcknowledgedAt {
syncResp.AcknowledgementsLastUpdateAt = acknowledgement.AcknowledgedAt
}
}
}
for _, status := range syncMsg.Statuses {
if err := scs.upsertSyncUserStatus(rctx, status, rc); err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "Error upserting sync user status",
mlog.String("remote", rc.Name),
mlog.String("user_id", status.UserId),
mlog.Err(err))
syncResp.StatusErrors = append(syncResp.StatusErrors, status.UserId)
}
}
// Process membership changes after users have been synced
if len(syncMsg.MembershipChanges) > 0 {
if err := scs.onReceiveMembershipChanges(syncMsg, rc); err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "Error processing membership changes",
mlog.String("remote", rc.Name),
mlog.String("channel_id", syncMsg.ChannelId),
mlog.Int("change_count", len(syncMsg.MembershipChanges)),
mlog.Err(err),
)
syncResp.MembershipErrors = append(syncResp.MembershipErrors, err.Error())
// Don't fail the entire sync if membership changes fail
}
}
return syncResp, nil
}
// processSyncMessageForRemote wraps ProcessSyncMessage for the remote cluster transport layer.
func (scs *Service) processSyncMessageForRemote(rctx request.CTX, syncMsg *model.SyncMsg, rc *model.RemoteCluster, response *remotecluster.Response) error {
syncResp, err := scs.ProcessSyncMessage(rctx, syncMsg, rc)
if err != nil {
return err
}
return response.SetPayload(syncResp)
}
func (scs *Service) upsertSyncUser(rctx request.CTX, user *model.User, channel *model.Channel, rc *model.RemoteCluster) (*model.User, error) {
var err error
// Check if user already exists
euser, err := scs.server.GetStore().User().Get(rctx, user.Id)
if err != nil {
if _, ok := err.(errNotFound); !ok {
return nil, fmt.Errorf("error checking sync user: %w", err)
}
}
var userSaved *model.User
if euser == nil {
// new user. Make sure the remoteID is correct and insert the record
// Preserve original remote ID before overwriting RemoteId
originalRemoteId := user.GetRemoteID()
user.RemoteId = new(rc.RemoteId)
if user.Props == nil || user.Props[model.UserPropsKeyOriginalRemoteId] == "" {
if originalRemoteId == "" {
originalRemoteId = rc.RemoteId // If no original RemoteId, use current sync sender
}
user.SetProp(model.UserPropsKeyOriginalRemoteId, originalRemoteId)
}
if userSaved, err = scs.insertSyncUser(rctx, user, channel, rc); err != nil {
return nil, err
}
} else {
// existing user. Make sure user belongs to the remote that issued the update
if euser.GetRemoteID() != rc.RemoteId {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "RemoteID mismatch sync'ing user",
mlog.String("remote", rc.Name),
mlog.String("user_id", user.Id),
mlog.String("existing_user_remote_id", euser.GetRemoteID()),
mlog.String("update_user_remote_id", user.GetRemoteID()),
)
return nil, fmt.Errorf("error updating user: %w", ErrRemoteIDMismatch)
}
// save the updated username and email in props
user.SetProp(model.UserPropsKeyRemoteUsername, user.Username)
user.SetProp(model.UserPropsKeyRemoteEmail, user.Email)
patch := &model.UserPatch{
Username: &user.Username,
Nickname: &user.Nickname,
FirstName: &user.FirstName,
LastName: &user.LastName,
Props: user.Props,
Position: &user.Position,
Locale: &user.Locale,
Timezone: user.Timezone,
}
if userSaved, err = scs.updateSyncUser(rctx, patch, euser, channel, rc); err != nil {
return nil, err
}
}
// Add user to team and channel. We do this here regardless of whether the user was
// just created or patched since there are three steps to adding a user
// (insert rec, add to team, add to channel) and any one could fail.
// Instead of undoing what succeeded on any failure we simply do all steps each
// time. AddUserToChannel & AddUserToTeamByTeamId do not error if user was already
// added and exit quickly. Not needed for DMs where teamId is empty.
if channel != nil && channel.TeamId != "" {
// add user to team
if err := scs.app.AddUserToTeamByTeamId(rctx, channel.TeamId, userSaved); err != nil {
return nil, fmt.Errorf("error adding sync user to Team: %w", err)
}
// add user to channel
if _, err := scs.app.AddUserToChannel(rctx, userSaved, channel, false); err != nil {
return nil, fmt.Errorf("error adding sync user to ChannelMembers: %w", err)
}
}
return userSaved, nil
}
func (scs *Service) insertSyncUser(rctx request.CTX, user *model.User, _ *model.Channel, rc *model.RemoteCluster) (*model.User, error) {
var err error
var userSaved *model.User
var suffix string
// ensure the new user is created with system_user role and random password.
user = sanitizeUserForSync(user)
// save the original username and email in props
user.SetProp(model.UserPropsKeyRemoteUsername, user.Username)
user.SetProp(model.UserPropsKeyRemoteEmail, user.Email)
// Apply a suffix to the username until it is unique. Collisions will be quite
// rare since we are joining a username that is unique at a remote site with a unique
// name for that site. However we need to truncate the combined name to 64 chars and
// that might introduce a collision.
for i := 1; i <= MaxUpsertRetries; i++ {
if i > 1 {
suffix = strconv.FormatInt(int64(i), 10)
}
user.Username = mungUsername(user.Username, rc.Name, suffix, model.UserNameMaxLength)
user.Email = model.NewId()
if userSaved, err = scs.server.GetStore().User().Save(rctx, user); err != nil {
field, ok := isConflictError(err)
if !ok {
break
}
if field == "email" || field == "username" {
// username or email collision; try again with different suffix
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "Collision inserting sync user",
mlog.String("field", field),
mlog.String("username", user.Username),
mlog.String("email", user.Email),
mlog.Int("attempt", i),
mlog.Err(err),
)
}
} else {
scs.app.NotifySharedChannelUserUpdate(userSaved)
return userSaved, nil
}
}
return nil, fmt.Errorf("error inserting sync user %s: %w", user.Id, err)
}
func (scs *Service) updateSyncUser(rctx request.CTX, patch *model.UserPatch, user *model.User, _ *model.Channel, rc *model.RemoteCluster) (*model.User, error) {
var err error
var update *model.UserUpdate
var suffix string
// preserve existing real username/email since Patch will over-write them;
// the real username/email in props can be updated if they don't contain colons,
// meaning the update is coming from the user's origin server (not munged).
realUsername, _ := user.GetProp(model.UserPropsKeyRemoteUsername)
realEmail, _ := user.GetProp(model.UserPropsKeyRemoteEmail)
if patch.Username != nil && !strings.Contains(*patch.Username, ":") {
realUsername = *patch.Username
}
if patch.Email != nil && !strings.Contains(*patch.Email, ":") {
realEmail = *patch.Email
}
user.Patch(patch)
user = sanitizeUserForSync(user)
user.SetProp(model.UserPropsKeyRemoteUsername, realUsername)
user.SetProp(model.UserPropsKeyRemoteEmail, realEmail)
// Apply a suffix to the username until it is unique.
for i := 1; i <= MaxUpsertRetries; i++ {
if i > 1 {
suffix = strconv.FormatInt(int64(i), 10)
}
user.Username = mungUsername(user.Username, rc.Name, suffix, model.UserNameMaxLength)
user.Email = model.NewId()
if update, err = scs.server.GetStore().User().Update(rctx, user, false); err != nil {
field, ok := isConflictError(err)
if !ok {
break
}
if field == "email" || field == "username" {
// username or email collision; try again with different suffix
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "Collision updating sync user",
mlog.String("field", field),
mlog.String("username", user.Username),
mlog.String("email", user.Email),
mlog.Int("attempt", i),
mlog.Err(err),
)
}
} else {
scs.platform.InvalidateCacheForUser(update.New.Id)
scs.app.NotifySharedChannelUserUpdate(update.New)
return update.New, nil
}
}
return nil, fmt.Errorf("error updating sync user %s: %w", user.Id, err)
}
func (scs *Service) upsertSyncPost(post *model.Post, targetChannel *model.Channel, rc *model.RemoteCluster, mentionTransforms map[string]string) (*model.Post, error) {
var appErr *model.AppError
post.RemoteId = new(rc.RemoteId)
rctx := request.EmptyContext(scs.server.Log())
rpost, err := scs.server.GetStore().Post().GetSingle(rctx, post.Id, true)
if err != nil {
if _, ok := err.(errNotFound); !ok {
return nil, fmt.Errorf("error checking sync post: %w", err)
}
}
// ensure the post is in the target channel. This ensures the post can only be associated with a channel
// that is shared with the remote.
if post.ChannelId != targetChannel.Id || (rpost != nil && rpost.ChannelId != targetChannel.Id) {
return nil, fmt.Errorf("post sync failed: %w", ErrChannelIDMismatch)
}
if rpost == nil {
// post doesn't exist; check that user belongs to remote and create post.
// user is not checked for edit/delete because admins can perform those actions
user, err := scs.server.GetStore().User().Get(rctx, post.UserId)
if err != nil {
return nil, fmt.Errorf("error fetching user for post sync: %w", err)
}
if user.GetRemoteID() != rc.RemoteId {
return nil, fmt.Errorf("post sync failed: %w", ErrRemoteIDMismatch)
}
scs.transformMentionsOnReceive(rctx, post, targetChannel, rc, mentionTransforms)
// The post is federated (RemoteId set) and its author is verified above
// to belong to the remote, which already enforced mm_blocks_actions
// authority. Preserve the prop through the create-time strip, mirroring
// how SanitizeProps preserves integration/notification props for
// federated posts.
rpost, _, appErr = scs.app.CreatePost(rctx, post, targetChannel, model.CreatePostFlags{
TriggerWebhooks: true,
SetOnline: true,
AllowMmBlocksActions: post.GetProp(model.PostPropsMmBlocksActions) != nil,
})
if appErr == nil {
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Created sync post",
mlog.String("post_id", post.Id),
mlog.String("channel_id", post.ChannelId),
)
}
} else if post.DeleteAt > 0 {
// make sure the post being deleted is owned by the remote
if rpost.GetRemoteID() != rc.RemoteId {
return nil, fmt.Errorf("post sync failed: %w", ErrRemoteIDMismatch)
}
// delete post
rpost, appErr = scs.app.DeletePost(rctx, post.Id, post.UserId)
if appErr == nil {
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Deleted sync post",
mlog.String("post_id", post.Id),
mlog.String("channel_id", post.ChannelId),
)
}
} else if post.EditAt > rpost.EditAt || post.Message != rpost.Message || post.UpdateAt > rpost.UpdateAt || post.Metadata != nil {
// make sure the post being edited is owned by the remote
if rpost.GetRemoteID() != rc.RemoteId {
return nil, fmt.Errorf("post sync failed: %w", ErrRemoteIDMismatch)
}
scs.transformMentionsOnReceive(rctx, post, targetChannel, rc, mentionTransforms)
var priority *model.PostPriority
var acknowledgements []*model.PostAcknowledgement
if post.Metadata != nil {
// Save the received priority
if post.Metadata.Priority != nil {
priority = post.Metadata.Priority
}
// Save the received acknowledgements
if post.Metadata.Acknowledgements != nil {
acknowledgements = post.Metadata.Acknowledgements
}
}
// First update the basic post. The post is federated and remote-owned
// (verified above); the origin cluster already enforced both
// mm_blocks_actions and integration-prop authority, so allow the synced
// values through the UpdatePost freeze — button edits made upstream
// propagate, and the remote's display-identity values are honored
// instead of restoring stale local ones from the prior post.
rpost, _, appErr = scs.app.UpdatePost(rctx, post, &model.UpdatePostOptions{
AllowMmBlocksActionsUpdate: true,
AllowIdentityPropsUpdate: true,
})
if appErr != nil {
rerr := errors.New(appErr.Error())
return nil, rerr
}
// Handle priority metadata separately if needed
if priority != nil {
rpost = scs.syncRemotePriorityMetadata(rctx, post, priority, rpost)
}
// Handle acknowledgements metadata separately if needed
if acknowledgements != nil {
rpost = scs.syncRemoteAcknowledgementsMetadata(rctx, post, acknowledgements, rpost)
}
} else {
// nothing to update
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Update to sync post ignored",
mlog.String("post_id", post.Id),
mlog.String("channel_id", post.ChannelId),
)
}
var rerr error
if appErr != nil {
rerr = errors.New(appErr.Error())
}
return rpost, rerr
}
// syncRemotePriorityMetadata handles syncing priority metadata from a remote post.
// It completely replaces existing priority settings with the ones from the remote post,
// regardless of update type.
func (scs *Service) syncRemotePriorityMetadata(rctx request.CTX, post *model.Post, priority *model.PostPriority, rpost *model.Post) *model.Post {
// First, create a new priority object with proper post and channel IDs
newPriority := &model.PostPriority{
PostId: post.Id,
ChannelId: post.ChannelId,
}
// Copy the priority values from the remote post
if priority.Priority != nil {
newPriority.Priority = priority.Priority
}
if priority.RequestedAck != nil {
newPriority.RequestedAck = priority.RequestedAck
}
if priority.PersistentNotifications != nil {
newPriority.PersistentNotifications = priority.PersistentNotifications
}
// Save the new priority - this will replace any existing priority for the post
savedPriority, priorityErr := scs.server.GetStore().PostPriority().Save(newPriority)
if priorityErr != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "Error saving post priority from remote",
mlog.String("post_id", post.Id),
mlog.String("channel_id", post.ChannelId),
mlog.Err(priorityErr),
)
} else {
// If the priority is successfully saved, ensure it's in the returned post
if rpost.Metadata == nil {
rpost.Metadata = &model.PostMetadata{}
}
// Use the saved priority from the database operation
rpost.Metadata.Priority = savedPriority
}
return rpost
}
// syncRemoteAcknowledgementsMetadata handles syncing acknowledgements metadata from a remote post.
// It replaces all existing acknowledgements with the ones from the remote post.
func (scs *Service) syncRemoteAcknowledgementsMetadata(rctx request.CTX, post *model.Post, acknowledgements []*model.PostAcknowledgement, rpost *model.Post) *model.Post {
// When syncing from remote, we completely replace the existing acknowledgements
// with the ones received from the remote, regardless of update type
// Get existing acknowledgements and delete them using batch operation
existingAcks, appErrGet := scs.app.GetAcknowledgementsForPost(post.Id)
if appErrGet != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "Error getting existing acknowledgements for remote sync",
mlog.String("post_id", post.Id),
mlog.Err(appErrGet),
)
} else if len(existingAcks) > 0 {
// Use batch delete for better performance
if nErr := scs.server.GetStore().PostAcknowledgement().BatchDelete(existingAcks); nErr != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "Error batch deleting acknowledgements for remote sync",
mlog.String("post_id", post.Id),
mlog.Int("count", len(existingAcks)),
mlog.Err(nErr),
)
}
}
// Extract all user IDs from acknowledgements for batch processing
userIDs := make([]string, 0, len(acknowledgements))
for _, ack := range acknowledgements {
userIDs = append(userIDs, ack.UserId)
}
// Use batch operation to save all acknowledgements at once
var savedAcks []*model.PostAcknowledgement
if len(userIDs) > 0 {
var appErrAck *model.AppError
savedAcks, appErrAck = scs.app.SaveAcknowledgementsForPost(rctx, post.Id, userIDs)
if appErrAck != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "Error syncing remote post acknowledgements",
mlog.String("post_id", post.Id),
mlog.Int("count", len(userIDs)),
mlog.Err(appErrAck),
)
// Fall back to original acknowledgements if batch save fails
savedAcks = acknowledgements
}
}
// Update acknowledgements in the returned post
if rpost.Metadata == nil {
rpost.Metadata = &model.PostMetadata{}
}
rpost.Metadata.Acknowledgements = savedAcks
return rpost
}
func (scs *Service) upsertSyncReaction(reaction *model.Reaction, targetChannel *model.Channel, rc *model.RemoteCluster) (*model.Reaction, error) {
savedReaction := reaction
var appErr *model.AppError
// check that the reaction's post is in the target channel. This ensures the reaction can only be associated with a post
// that is in a channel shared with the remote.
rctx := request.EmptyContext(scs.server.Log())
post, err := scs.server.GetStore().Post().GetSingle(rctx, reaction.PostId, true)
if err != nil {
return nil, fmt.Errorf("error fetching post for reaction sync: %w", err)
}
if post.ChannelId != targetChannel.Id {
return nil, fmt.Errorf("reaction sync failed: %w", ErrChannelIDMismatch)
}
existingReaction, err := scs.server.GetStore().Reaction().GetSingle(reaction.UserId, reaction.PostId, rc.RemoteId, reaction.EmojiName)
if err != nil && !isNotFoundError(err) {
return nil, fmt.Errorf("error fetching reaction for sync: %w", err)
}
if existingReaction == nil {
// reaction does not exist; check that user belongs to remote and create reaction
// this is not done for delete since deletion can be done by admins on the remote
user, err := scs.server.GetStore().User().Get(rctx, reaction.UserId)
if err != nil {
return nil, fmt.Errorf("error fetching user for reaction sync: %w", err)
}
if user.GetRemoteID() != rc.RemoteId {
return nil, fmt.Errorf("reaction sync failed: %w", ErrRemoteIDMismatch)
}
reaction.RemoteId = new(rc.RemoteId)
savedReaction, appErr = scs.app.SaveReactionForPost(request.EmptyContext(scs.server.Log()), reaction)
} else {
// make sure the reaction being deleted is owned by the remote
if existingReaction.GetRemoteID() != rc.RemoteId {
return nil, fmt.Errorf("reaction sync failed: %w", ErrRemoteIDMismatch)
}
appErr = scs.app.DeleteReactionForPost(request.EmptyContext(scs.server.Log()), reaction)
}
var retErr error
if appErr != nil {
retErr = errors.New(appErr.Error())
}
return savedReaction, retErr
}
func (scs *Service) upsertSyncAcknowledgement(acknowledgement *model.PostAcknowledgement, targetChannel *model.Channel, rc *model.RemoteCluster) (*model.PostAcknowledgement, error) {
savedAcknowledgement := acknowledgement
var appErr *model.AppError
// check that the acknowledgement's post is in the target channel. This ensures the acknowledgement can only be associated with a post
// that is in a channel shared with the remote.
rctx := request.EmptyContext(scs.server.Log())
post, err := scs.server.GetStore().Post().GetSingle(rctx, acknowledgement.PostId, true)
if err != nil {
return nil, fmt.Errorf("error fetching post for acknowledgement sync: %w", err)
}
if post.ChannelId != targetChannel.Id {
return nil, fmt.Errorf("acknowledgement sync failed: %w", ErrChannelIDMismatch)
}
existingAcknowledgement, err := scs.server.GetStore().PostAcknowledgement().GetSingle(acknowledgement.UserId, acknowledgement.PostId, rc.RemoteId)
if err != nil && !isNotFoundError(err) {
return nil, fmt.Errorf("error fetching acknowledgement for sync: %w", err)
}
if existingAcknowledgement == nil {
// acknowledgement does not exist; check that user belongs to remote and create acknowledgement
// this is not done for delete since deletion can be done by admins on the remote
user, err := scs.server.GetStore().User().Get(rctx, acknowledgement.UserId)
if err != nil {
return nil, fmt.Errorf("error fetching user for acknowledgement sync: %w", err)
}
if user.GetRemoteID() != rc.RemoteId {
return nil, fmt.Errorf("acknowledgement sync failed: %w", ErrRemoteIDMismatch)
}
acknowledgement.RemoteId = new(rc.RemoteId)
acknowledgement.ChannelId = targetChannel.Id
savedAcknowledgement, appErr = scs.app.SaveAcknowledgementForPostWithModel(request.EmptyContext(scs.server.Log()), acknowledgement)
} else {
// make sure the acknowledgement being deleted is owned by the remote
if existingAcknowledgement.GetRemoteID() != rc.RemoteId {
return nil, fmt.Errorf("acknowledgement sync failed: %w", ErrRemoteIDMismatch)
}
if acknowledgement.AcknowledgedAt == 0 {
// Delete the acknowledgement
appErr = scs.app.DeleteAcknowledgementForPostWithModel(request.EmptyContext(scs.server.Log()), acknowledgement)
}
}
var retErr error
if appErr != nil {
retErr = errors.New(appErr.Error())
}
return savedAcknowledgement, retErr
}
func (scs *Service) upsertSyncUserStatus(rctx request.CTX, status *model.Status, rc *model.RemoteCluster) error {
user, err := scs.server.GetStore().User().Get(rctx, status.UserId)
if err != nil {
return fmt.Errorf("error getting user when syncing status: %w", err)
}
if user.GetRemoteID() != rc.RemoteId {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "RemoteID mismatch sync'ing user status",
mlog.String("remote", rc.Name),
mlog.String("user_id", status.UserId),
mlog.String("user_remote_id", user.GetRemoteID()),
)
return fmt.Errorf("error updating user status: %w", ErrRemoteIDMismatch)
}
scs.app.SaveAndBroadcastStatus(status)
return nil
}
// transformMentionsOnReceive transforms mentions in received posts using explicit mentionTransforms.
func (scs *Service) transformMentionsOnReceive(rctx request.CTX, post *model.Post, targetChannel *model.Channel, rc *model.RemoteCluster, mentionTransforms map[string]string) {
if post.Message == "" || len(mentionTransforms) == 0 {
return
}
// Process mentions directly using mentionTransforms - no need to re-parse with regex
for mention, userID := range mentionTransforms {
oldMention := "@" + mention
var newMention string
// Get the user to determine transformation type
if user, err := scs.server.GetStore().User().Get(rctx, userID); err == nil && user != nil {
// User exists in receiver's database
if strings.Contains(mention, ":") {
// Colon mention (e.g., "@admin:remote1") - always use the user's actual username
newMention = "@" + user.Username
} else {
// Simple mention (e.g., "@admin")
if user.GetRemoteID() == "" {
// This is a local user, keep as-is
newMention = "@" + mention
} else {
// This is a remote user that was synced, use their synced username
newMention = "@" + user.Username
}
}
} else {
// User doesn't exist in receiver's database
if strings.Contains(mention, ":") {
// Colon mention for unknown user - keep as-is
newMention = oldMention
} else {
// Simple mention for unknown user - add cluster suffix to indicate it's from remote
newMention = "@" + mention + ":" + rc.Name
}
}
post.Message = strings.ReplaceAll(post.Message, oldMention, newMention)
}
}

View File

@@ -0,0 +1,371 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sharedchannel
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
)
func TestProcessSyncMessage(t *testing.T) {
remoteID := model.NewId()
channelID := model.NewId()
rc := &model.RemoteCluster{
RemoteId: remoteID,
Name: "test-remote",
}
makeConfig := func() *model.Config {
cfg := &model.Config{}
cfg.SetDefaults()
return cfg
}
t.Run("returns SyncResponse directly for empty sync message", func(t *testing.T) {
channel := &model.Channel{Id: channelID, Type: model.ChannelTypeOpen}
mockChannelStore := &mocks.ChannelStore{}
mockChannelStore.On("Get", channelID, true).Return(channel, nil)
mockSCStore := &mocks.SharedChannelStore{}
mockSCStore.On("HasRemote", channelID, remoteID).Return(true, nil)
mockStore := &mocks.Store{}
mockStore.On("Channel").Return(mockChannelStore)
mockStore.On("SharedChannel").Return(mockSCStore)
logger := mlog.CreateConsoleTestLogger(t)
mockServer := &MockServerIface{}
mockServer.On("GetStore").Return(mockStore)
mockServer.On("Log").Return(logger)
mockServer.On("Config").Return(makeConfig())
scs := &Service{server: mockServer}
syncMsg := &model.SyncMsg{
ChannelId: channelID,
}
resp, err := scs.ProcessSyncMessage(request.TestContext(t), syncMsg, rc)
require.NoError(t, err)
assert.Empty(t, resp.PostErrors)
assert.Empty(t, resp.UserErrors)
})
t.Run("returns error when channel not found", func(t *testing.T) {
mockChannelStore := &mocks.ChannelStore{}
mockChannelStore.On("Get", channelID, true).Return(nil, store.NewErrNotFound("Channel", channelID))
mockStore := &mocks.Store{}
mockStore.On("Channel").Return(mockChannelStore)
logger := mlog.CreateConsoleTestLogger(t)
mockServer := &MockServerIface{}
mockServer.On("GetStore").Return(mockStore)
mockServer.On("Log").Return(logger)
mockServer.On("Config").Return(makeConfig())
scs := &Service{server: mockServer}
syncMsg := &model.SyncMsg{
ChannelId: channelID,
}
_, err := scs.ProcessSyncMessage(request.TestContext(t), syncMsg, rc)
require.Error(t, err)
assert.Contains(t, err.Error(), "channel not found")
})
t.Run("returns error when channel not shared with remote", func(t *testing.T) {
channel := &model.Channel{Id: channelID, Type: model.ChannelTypeOpen}
mockChannelStore := &mocks.ChannelStore{}
mockChannelStore.On("Get", channelID, true).Return(channel, nil)
mockSCStore := &mocks.SharedChannelStore{}
mockSCStore.On("HasRemote", channelID, remoteID).Return(false, nil)
mockStore := &mocks.Store{}
mockStore.On("Channel").Return(mockChannelStore)
mockStore.On("SharedChannel").Return(mockSCStore)
logger := mlog.CreateConsoleTestLogger(t)
mockServer := &MockServerIface{}
mockServer.On("GetStore").Return(mockStore)
mockServer.On("Log").Return(logger)
mockServer.On("Config").Return(makeConfig())
scs := &Service{server: mockServer}
syncMsg := &model.SyncMsg{
ChannelId: channelID,
}
_, err := scs.ProcessSyncMessage(request.TestContext(t), syncMsg, rc)
require.Error(t, err)
assert.ErrorIs(t, err, ErrChannelNotShared)
})
t.Run("returns error for global user sync with posts", func(t *testing.T) {
logger := mlog.CreateConsoleTestLogger(t)
mockServer := &MockServerIface{}
mockServer.On("Log").Return(logger)
mockServer.On("Config").Return(makeConfig())
scs := &Service{server: mockServer}
syncMsg := &model.SyncMsg{
ChannelId: "", // global user sync
Posts: []*model.Post{{Id: model.NewId()}},
}
_, err := scs.ProcessSyncMessage(request.TestContext(t), syncMsg, rc)
require.Error(t, err)
assert.Contains(t, err.Error(), "should not contain posts")
})
t.Run("returns empty response for global user sync with no users", func(t *testing.T) {
logger := mlog.CreateConsoleTestLogger(t)
mockServer := &MockServerIface{}
mockServer.On("Log").Return(logger)
mockServer.On("Config").Return(makeConfig())
scs := &Service{server: mockServer}
syncMsg := &model.SyncMsg{
ChannelId: "", // global user sync
}
resp, err := scs.ProcessSyncMessage(request.TestContext(t), syncMsg, rc)
require.NoError(t, err)
assert.Empty(t, resp.UsersSyncd)
})
t.Run("post with mismatched channelID is reported in PostErrors", func(t *testing.T) {
channel := &model.Channel{Id: channelID, Type: model.ChannelTypeOpen}
wrongChannelID := model.NewId()
postID := model.NewId()
mockChannelStore := &mocks.ChannelStore{}
mockChannelStore.On("Get", channelID, true).Return(channel, nil)
mockSCStore := &mocks.SharedChannelStore{}
mockSCStore.On("HasRemote", channelID, remoteID).Return(true, nil)
mockStore := &mocks.Store{}
mockStore.On("Channel").Return(mockChannelStore)
mockStore.On("SharedChannel").Return(mockSCStore)
logger := mlog.CreateConsoleTestLogger(t)
mockServer := &MockServerIface{}
mockServer.On("GetStore").Return(mockStore)
mockServer.On("Log").Return(logger)
mockServer.On("Config").Return(makeConfig())
scs := &Service{server: mockServer}
syncMsg := &model.SyncMsg{
ChannelId: channelID,
Posts: []*model.Post{
{Id: postID, ChannelId: wrongChannelID},
},
}
resp, err := scs.ProcessSyncMessage(request.TestContext(t), syncMsg, rc)
require.NoError(t, err)
assert.Contains(t, resp.PostErrors, postID)
})
}
func TestUpsertSyncUserStatus(t *testing.T) {
setup := func(remoteID string, user *model.User) (*Service, *MockAppIface, *model.Status, *model.RemoteCluster) {
var userID string
if user == nil {
userID = model.NewId()
} else {
userID = user.Id
}
status := &model.Status{
UserId: userID,
Status: model.StatusDnd,
}
remoteCluster := &model.RemoteCluster{
RemoteId: remoteID,
Name: "test-remote",
}
mockUserStore := &mocks.UserStore{}
if user == nil {
mockUserStore.On("Get", mockTypeContext, mock.Anything).Return(nil, store.NewErrNotFound("User", userID))
} else {
mockUserStore.On("Get", mockTypeContext, user.Id).Return(user, nil)
}
mockStore := &mocks.Store{}
mockStore.On("User").Return(mockUserStore)
logger := mlog.CreateConsoleTestLogger(t)
mockServer := &MockServerIface{}
mockServer.On("GetStore").Return(mockStore)
mockServer.On("Log").Return(logger)
mockApp := &MockAppIface{}
mockApp.On("SaveAndBroadcastStatus", status).Return()
scs := &Service{
server: mockServer,
app: mockApp,
}
return scs, mockApp, status, remoteCluster
}
t.Run("should broadcast changes to a remote user's status", func(t *testing.T) {
remoteID := model.NewId()
user := &model.User{
Id: model.NewId(),
RemoteId: new(remoteID),
}
scs, mockApp, status, remoteCluster := setup(remoteID, user)
err := scs.upsertSyncUserStatus(request.TestContext(t), status, remoteCluster)
require.NoError(t, err)
mockApp.AssertCalled(t, "SaveAndBroadcastStatus", status)
})
t.Run("should return an error when the user doesn't exist locally", func(t *testing.T) {
remoteID := model.NewId()
var user *model.User
scs, mockApp, status, remoteCluster := setup(remoteID, user)
rctx := request.TestContext(t)
err := scs.upsertSyncUserStatus(rctx, status, remoteCluster)
require.Error(t, err)
assert.Contains(t, err.Error(), "error getting user when syncing status")
mockApp.AssertNotCalled(t, "SaveAndBroadcastStatus")
})
t.Run("should return an error when attempting to sync a local user", func(t *testing.T) {
remoteID := model.NewId()
user := &model.User{
Id: model.NewId(),
RemoteId: nil,
}
scs, mockApp, status, remoteCluster := setup(remoteID, user)
rctx := request.TestContext(t)
err := scs.upsertSyncUserStatus(rctx, status, remoteCluster)
require.Error(t, err)
assert.ErrorIs(t, err, ErrRemoteIDMismatch)
assert.Contains(t, err.Error(), "error updating user status")
mockApp.AssertNotCalled(t, "SaveAndBroadcastStatus")
})
t.Run("should return an error when attempting to sync a user from a different remote", func(t *testing.T) {
remoteID := model.NewId()
anotherRemoteID := model.NewId()
user := &model.User{
Id: model.NewId(),
RemoteId: new(anotherRemoteID),
}
scs, mockApp, status, remoteCluster := setup(remoteID, user)
rctx := request.TestContext(t)
err := scs.upsertSyncUserStatus(rctx, status, remoteCluster)
require.Error(t, err)
assert.ErrorIs(t, err, ErrRemoteIDMismatch)
assert.Contains(t, err.Error(), "error updating user status")
mockApp.AssertNotCalled(t, "SaveAndBroadcastStatus")
})
}
func TestUpsertSyncPost(t *testing.T) {
remoteID := model.NewId()
channelID := model.NewId()
channel := &model.Channel{Id: channelID, Type: model.ChannelTypeOpen}
rc := &model.RemoteCluster{RemoteId: remoteID, Name: "test-remote"}
setup := func(t *testing.T, existing *model.Post) (*Service, *MockAppIface) {
mockPostStore := &mocks.PostStore{}
mockPostStore.On("GetSingle", mock.Anything, existing.Id, true).Return(existing, nil)
mockStore := &mocks.Store{}
mockStore.On("Post").Return(mockPostStore)
logger := mlog.CreateConsoleTestLogger(t)
mockServer := &MockServerIface{}
mockServer.On("GetStore").Return(mockStore)
mockServer.On("Log").Return(logger)
mockApp := &MockAppIface{}
return &Service{server: mockServer, app: mockApp}, mockApp
}
t.Run("rejects edit of a post owned by a different remote", func(t *testing.T) {
otherRemoteID := model.NewId()
postID := model.NewId()
existing := &model.Post{Id: postID, ChannelId: channelID, Message: "original", RemoteId: new(otherRemoteID)}
scs, mockApp := setup(t, existing)
_, err := scs.upsertSyncPost(&model.Post{Id: postID, ChannelId: channelID, Message: "tampered"}, channel, rc, nil)
require.Error(t, err)
assert.ErrorIs(t, err, ErrRemoteIDMismatch)
mockApp.AssertNotCalled(t, "UpdatePost", mock.Anything, mock.Anything, mock.Anything)
})
t.Run("rejects delete of a post owned by a local user", func(t *testing.T) {
postID := model.NewId()
existing := &model.Post{Id: postID, ChannelId: channelID, Message: "original", RemoteId: nil}
scs, mockApp := setup(t, existing)
_, err := scs.upsertSyncPost(&model.Post{Id: postID, ChannelId: channelID, DeleteAt: model.GetMillis()}, channel, rc, nil)
require.Error(t, err)
assert.ErrorIs(t, err, ErrRemoteIDMismatch)
mockApp.AssertNotCalled(t, "DeletePost", mock.Anything, mock.Anything, mock.Anything)
})
t.Run("allows edit of a post owned by the sending remote", func(t *testing.T) {
postID := model.NewId()
existing := &model.Post{Id: postID, ChannelId: channelID, Message: "original", RemoteId: new(remoteID)}
scs, mockApp := setup(t, existing)
updated := &model.Post{Id: postID, ChannelId: channelID, Message: "updated"}
// A federated, remote-owned edit must be allowed to carry mm_blocks_actions
// changes through the UpdatePost freeze — the origin cluster is the trust
// boundary. Assert the option is set so a revert to nil options is caught.
allowsMmBlocks := mock.MatchedBy(func(opts *model.UpdatePostOptions) bool {
return opts != nil && opts.AllowMmBlocksActionsUpdate
})
mockApp.On("UpdatePost", mock.Anything, mock.Anything, allowsMmBlocks).Return(updated, false, (*model.AppError)(nil))
_, err := scs.upsertSyncPost(&model.Post{Id: postID, ChannelId: channelID, Message: "updated"}, channel, rc, nil)
require.NoError(t, err)
mockApp.AssertCalled(t, "UpdatePost", mock.Anything, mock.Anything, allowsMmBlocks)
})
}

View File

@@ -0,0 +1,748 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sharedchannel
import (
"context"
"database/sql"
"errors"
"fmt"
"slices"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/platform/services/remotecluster"
)
type syncTask struct {
id string
channelID string
userID string
remoteID string
AddedAt time.Time
// existingMsg is used to add information to the task on creation
// instead of waiting until the task is processed to fetch it. If
// a new task with the same ID is scheduled, its existingMsg will
// replace the previous one
existingMsg *model.SyncMsg
retryCount int
retryMsg *model.SyncMsg
schedule time.Time
// originRemoteID is the remote that initiated this change; it will be
// skipped when syncing to prevent echo-back.
originRemoteID string
}
func newSyncTask(channelID, userID string, remoteID string, existingMsg, retryMsg *model.SyncMsg) syncTask {
var retryID string
if retryMsg != nil {
retryID = retryMsg.Id
}
// Generate a unique task ID
taskID := channelID + userID + remoteID + retryID // combination of ids to avoid duplicates
// For batch tasks, add a batch identifier to make the ID unique
if existingMsg != nil && len(existingMsg.MembershipChanges) > 1 {
batchID := model.NewId()[:8] // Use a short unique ID for the batch
taskID = channelID + "batch" + batchID + remoteID + retryID
}
return syncTask{
id: taskID,
channelID: channelID,
userID: userID,
remoteID: remoteID, // empty means update all remote clusters
existingMsg: existingMsg,
retryMsg: retryMsg,
schedule: time.Now(),
}
}
// incRetry increments the retry counter and returns true if MaxRetries not exceeded.
func (st *syncTask) incRetry() bool {
st.retryCount++
return st.retryCount <= MaxRetries
}
// NotifyChannelChanged is called to indicate that a shared channel has been modified,
// thus triggering an update to all remote clusters.
func (scs *Service) NotifyChannelChanged(channelID string) {
if rcs := scs.server.GetRemoteClusterService(); rcs == nil {
return
}
task := newSyncTask(channelID, "", "", nil, nil)
task.schedule = time.Now().Add(NotifyMinimumDelay)
scs.addTask(task)
}
// NotifyUserProfileChanged is called to indicate that a user has modified their user
// profile (name, username, email, custom status, profile image)
func (scs *Service) NotifyUserProfileChanged(userID string) {
if rcs := scs.server.GetRemoteClusterService(); rcs == nil {
return
}
scusers, err := scs.server.GetStore().SharedChannel().GetUsersForUser(userID)
if err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "Failed to fetch shared channel users",
mlog.String("user_id", userID),
mlog.Err(err),
)
return
}
if len(scusers) == 0 {
return
}
notified := make(map[string]struct{})
for _, user := range scusers {
// update every user + remote combination they belong to.
// Redundant updates (ie. to same remote for multiple channels) will be
// filtered out.
combo := user.UserId + user.RemoteId
if _, ok := notified[combo]; ok {
continue
}
notified[combo] = struct{}{}
task := newSyncTask(user.ChannelId, "", user.RemoteId, nil, nil)
task.schedule = time.Now().Add(NotifyMinimumDelay)
scs.addTask(task)
}
}
// NotifyUserStatusChanged is called to indicate that a user has modified their status
func (scs *Service) NotifyUserStatusChanged(status *model.Status) {
if rcs := scs.server.GetRemoteClusterService(); rcs == nil {
return
}
if *scs.server.Config().ConnectedWorkspacesSettings.DisableSharedChannelsStatusSync {
return
}
if status.UserId == "" {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "Received invalid status for sync",
mlog.String("user_id", status.UserId),
)
return
}
scusers, err := scs.server.GetStore().SharedChannel().GetUsersForUser(status.UserId)
if err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "Failed to fetch shared channel users",
mlog.String("user_id", status.UserId),
mlog.Err(err),
)
return
}
if len(scusers) == 0 {
return
}
existingMsg := &model.SyncMsg{Statuses: []*model.Status{status}}
notified := make(map[string]struct{})
for _, user := range scusers {
// update every user + remote combination they belong to.
// Redundant updates (ie. to same remote for multiple channels) will be
// filtered out.
combo := user.UserId + user.RemoteId
if _, ok := notified[combo]; ok {
continue
}
notified[combo] = struct{}{}
task := newSyncTask(user.ChannelId, user.UserId, user.RemoteId, existingMsg, nil)
task.schedule = time.Now().Add(NotifyMinimumDelay)
scs.addTask(task)
}
}
func (scs *Service) SendPendingInvitesForRemote(rc *model.RemoteCluster) {
if rcs := scs.server.GetRemoteClusterService(); rcs == nil {
return
}
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Processing pending invites for remote after reconnection",
mlog.String("remote", rc.DisplayName),
mlog.String("remote_id", rc.RemoteId),
)
opts := model.SharedChannelRemoteFilterOpts{
RemoteId: rc.RemoteId,
ExcludeConfirmed: true,
}
scrs, err := scs.server.GetStore().SharedChannel().GetRemotes(0, 999999, opts)
if err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "Failed to fetch shared channel remotes for pending invites",
mlog.String("remote", rc.DisplayName),
mlog.String("remote_id", rc.RemoteId),
mlog.Err(err),
)
return
}
for _, scr := range scrs {
channel, err := scs.server.GetStore().Channel().Get(scr.ChannelId, true)
if err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "Failed to fetch channel for pending invite",
mlog.String("remote_id", scr.RemoteId),
mlog.String("channel_id", scr.ChannelId),
mlog.String("sharedchannelremote_id", scr.Id),
mlog.Err(err),
)
continue
}
if err := scs.SendChannelInvite(channel, scr.CreatorId, rc); err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "Failed to send pending invite",
mlog.String("remote_id", scr.RemoteId),
mlog.String("channel_id", scr.ChannelId),
mlog.String("sharedchannelremote_id", scr.Id),
mlog.Err(err),
)
continue
}
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Pending invite sent",
mlog.String("remote", rc.DisplayName),
mlog.String("remote_id", rc.RemoteId),
mlog.String("channel_id", scr.ChannelId),
mlog.String("sharedchannelremote_id", scr.Id),
)
}
}
// ForceSyncForRemote causes all channels shared with the remote to be synchronized.
func (scs *Service) ForceSyncForRemote(rc *model.RemoteCluster) {
if rcs := scs.server.GetRemoteClusterService(); rcs == nil {
return
}
// fetch all channels shared with this remote.
opts := model.SharedChannelRemoteFilterOpts{
RemoteId: rc.RemoteId,
}
scrs, err := scs.server.GetStore().SharedChannel().GetRemotes(0, 999999, opts)
if err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "Failed to fetch shared channel remotes",
mlog.String("remote", rc.DisplayName),
mlog.String("remote_id", rc.RemoteId),
mlog.Err(err),
)
return
}
for _, scr := range scrs {
task := newSyncTask(scr.ChannelId, "", rc.RemoteId, nil, nil)
task.schedule = time.Now().Add(NotifyMinimumDelay)
scs.addTask(task)
}
}
// addTask adds or re-adds a task to the queue.
func (scs *Service) addTask(task syncTask) {
task.AddedAt = time.Now()
scs.mux.Lock()
if originalTask, ok := scs.tasks[task.id]; ok {
// if the task was already scheduled, we only update the
// existingMsg in case there is new information
originalTask.existingMsg = task.existingMsg
// originRemoteID identifies which remote initiated a change so processTask
// can skip sending back to that remote. When multiple events merge within
// the NotifyMinimumDelay window we can only safely skip a remote if every
// merged event came from that same remote. If the origins differ (e.g.
// remote-A join + remote-B join, or remote join + local join) we must clear
// originRemoteID so the sync fans out to all remotes. The receiver is
// idempotent, so the worst case is a redundant sync to the originating remote.
if task.originRemoteID != originalTask.originRemoteID {
originalTask.originRemoteID = ""
}
scs.tasks[task.id] = originalTask
} else {
scs.tasks[task.id] = task
}
scs.mux.Unlock()
// wake up the sync goroutine
select {
case scs.changeSignal <- struct{}{}:
default:
// that's ok, the sync routine is already busy
}
}
// syncLoop is called via a dedicated goroutine to wait for notifications of channel changes and
// updates each remote based on those changes.
func (scs *Service) syncLoop(done chan struct{}) {
// create a timer to periodically check the task queue, but only if there is
// a delayed task in the queue.
delay := time.NewTimer(NotifyMinimumDelay)
defer stopTimer(delay)
// wait for channel changed signal and update for oldest task.
for {
select {
case <-scs.changeSignal:
if wait := scs.doSync(); wait > 0 {
stopTimer(delay)
delay.Reset(wait)
}
case <-delay.C:
if wait := scs.doSync(); wait > 0 {
delay.Reset(wait)
}
case <-done:
return
}
}
}
func stopTimer(timer *time.Timer) {
timer.Stop()
select {
case <-timer.C:
default:
}
}
// doSync checks the task queue for any tasks to be processed and processes all that are ready.
// If any delayed tasks remain in queue then the duration until the next scheduled task is returned.
func (scs *Service) doSync() time.Duration {
var task syncTask
var ok bool
var shortestWait time.Duration
metrics := scs.server.GetMetrics()
if metrics != nil {
scs.mux.Lock()
size := len(scs.tasks)
scs.mux.Unlock()
metrics.ObserveSharedChannelsQueueSize(int64(size))
}
for {
task, ok, shortestWait = scs.removeOldestTask()
if !ok {
break
}
if metrics != nil {
metrics.ObserveSharedChannelsTaskInQueueDuration(time.Since(task.AddedAt).Seconds())
}
if err := scs.processTask(task); err != nil {
// put task back into map so it will update again
if task.incRetry() {
task.schedule = time.Now().Add(SyncRetryDelay)
scs.addTask(task)
} else {
scs.server.Log().Error("Failed to synchronize shared channel",
mlog.String("channel_id", task.channelID),
mlog.String("remote_id", task.remoteID),
mlog.Err(err),
)
}
}
}
return shortestWait
}
// removeOldestTask removes and returns the oldest task in the task map.
// A task coming in via NotifyChannelChanged must stay in queue for at least
// `NotifyMinimumDelay` to ensure we don't go nuts trying to sync during a bulk update.
// If no tasks are available then false is returned.
func (scs *Service) removeOldestTask() (syncTask, bool, time.Duration) {
scs.mux.Lock()
defer scs.mux.Unlock()
var oldestTask syncTask
var oldestKey string
var shortestWait time.Duration
for key, task := range scs.tasks {
// check if task is ready
if wait := time.Until(task.schedule); wait > 0 {
if wait < shortestWait || shortestWait == 0 {
shortestWait = wait
}
continue
}
// task is ready; check if it's the oldest ready task
if task.AddedAt.Before(oldestTask.AddedAt) || oldestTask.AddedAt.IsZero() {
oldestKey = key
oldestTask = task
}
}
if oldestKey != "" {
delete(scs.tasks, oldestKey)
return oldestTask, true, shortestWait
}
return oldestTask, false, shortestWait
}
// processTask updates one or more remote clusters with any new channel content.
func (scs *Service) processTask(task syncTask) error {
// map is used to ensure remotes don't get sync'd twice, such as when
// they have the autoinvited flag and have explicitly subscribed to a channel.
remotesMap := make(map[string]*model.RemoteCluster)
if task.remoteID == "" {
filter := model.RemoteClusterQueryFilter{
InChannel: task.channelID,
OnlyConfirmed: true,
}
remotes, err := scs.server.GetStore().RemoteCluster().GetAll(0, 999999, filter)
if err != nil {
return err
}
for _, r := range remotes {
// Skip the remote that originated this membership change
if task.originRemoteID != "" && r.RemoteId == task.originRemoteID {
continue
}
remotesMap[r.RemoteId] = r
}
// add all confirmed remotes that have the autoinvited option.
filter = model.RemoteClusterQueryFilter{
RequireOptions: model.BitflagOptionAutoInvited,
OnlyConfirmed: true,
}
remotesAutoInvited, err := scs.server.GetStore().RemoteCluster().GetAll(0, 999999, filter)
if err != nil {
return err
}
for _, r := range remotesAutoInvited {
// Skip the remote that originated this membership change
if task.originRemoteID != "" && r.RemoteId == task.originRemoteID {
continue
}
remotesMap[r.RemoteId] = r
}
} else {
rc, err := scs.server.GetStore().RemoteCluster().Get(task.remoteID, false)
if err != nil {
// The remote cluster has been deleted or no longer exists; there is
// nothing to sync, so drop the task rather than retrying and logging
// an error on every change for the orphaned reference.
if errors.Is(err, sql.ErrNoRows) {
scs.selfHealOrphanedSharedChannelRemote(task.channelID, task.remoteID)
return nil
}
return err
}
if !rc.IsOnline() {
return fmt.Errorf("Failed updating shared channel '%s' for offline remote cluster '%s'", task.channelID, rc.DisplayName)
}
remotesMap[rc.RemoteId] = rc
}
for _, rc := range remotesMap {
rtask := task
rtask.remoteID = rc.RemoteId
if err := scs.syncForRemote(rtask, rc); err != nil {
// retry, spaced by SyncRetryDelay to avoid hammering the remote...
if rtask.incRetry() {
// A task with no specific remote fans out to every remote in this loop, and
// each rtask is a copy that still carries the original (remote-less) task id.
// Give the per-remote retry a distinct, remote-specific id so addTask (which
// merges on id) keeps a separate retry task per failed remote instead of
// collapsing them into one. Single-remote tasks already have a remote-specific
// id, so leave those untouched (recomputing would grow the id on every retry).
if task.remoteID == "" {
rtask.id = task.id + rc.RemoteId
}
rtask.schedule = time.Now().Add(SyncRetryDelay)
scs.addTask(rtask)
} else {
scs.server.Log().Error("Failed to synchronize shared channel for remote cluster",
mlog.String("channel_id", rtask.channelID),
mlog.String("remote", rc.DisplayName),
mlog.Err(err),
)
}
}
}
return nil
}
// selfHealOrphanedSharedChannelRemote handles a SharedChannelRemote row that still
// references a RemoteCluster which has been soft-deleted or removed. Left alone, the
// live SCR row (DeleteAt = 0) keeps getting picked up by the sync loop, spamming a log
// entry on every tick. Soft-deleting the orphaned SCR row stops the recurrence without
// requiring manual DB intervention.
func (scs *Service) selfHealOrphanedSharedChannelRemote(channelID, remoteID string) {
scr, err := scs.server.GetStore().SharedChannel().GetRemoteByIds(channelID, remoteID)
if err != nil {
// The SCR row is already gone (or unreadable); nothing left to self-heal.
scs.server.Log().Warn("Skipping sync for deleted remote cluster",
mlog.String("channel_id", channelID),
mlog.String("remote_id", remoteID),
mlog.Err(err),
)
return
}
if scr.DeleteAt != 0 {
// The SCR row is already soft-deleted, so there is nothing to heal. A deleted
// remote no longer syncing is the expected steady state, so this is logged at
// debug rather than warn to avoid noise.
scs.server.Log().Debug("Skipping sync for deleted remote cluster",
mlog.String("channel_id", channelID),
mlog.String("remote_id", remoteID),
)
return
}
if _, err := scs.server.GetStore().SharedChannel().DeleteRemote(scr.Id); err != nil {
scs.server.Log().Warn("Failed to self-heal orphaned shared channel remote for deleted remote cluster",
mlog.String("channel_id", channelID),
mlog.String("remote_id", remoteID),
mlog.String("shared_channel_remote_id", scr.Id),
mlog.Err(err),
)
return
}
scs.server.Log().Warn("Self-healed orphaned shared channel remote for deleted remote cluster",
mlog.String("channel_id", channelID),
mlog.String("remote_id", remoteID),
mlog.String("shared_channel_remote_id", scr.Id),
)
}
func (scs *Service) handlePostError(postId string, task syncTask, rc *model.RemoteCluster) {
if task.retryMsg != nil && len(task.retryMsg.Posts) == 1 && task.retryMsg.Posts[0].Id == postId {
// this was a retry for specific post that failed previously. Try again if within MaxRetries.
if task.incRetry() {
scs.addTask(task)
} else {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "error syncing post",
mlog.String("remote", rc.DisplayName),
mlog.String("post_id", postId),
)
}
return
}
// this post failed as part of a group of posts. Retry as an individual post.
post, err := scs.server.GetStore().Post().GetSingle(request.EmptyContext(scs.server.Log()), postId, true)
if err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "error fetching post for sync retry",
mlog.String("remote", rc.DisplayName),
mlog.String("post_id", postId),
mlog.Err(err),
)
return
}
// Populate metadata for the retry post
post = scs.app.PreparePostForClient(request.EmptyContext(scs.server.Log()), post, &model.PreparePostForClientOpts{IncludePriority: true})
syncMsg := model.NewSyncMsg(task.channelID)
syncMsg.Posts = []*model.Post{post}
scs.addTask(newSyncTask(task.channelID, task.userID, task.remoteID, nil, syncMsg))
}
func (scs *Service) handleStatusError(userId string, task syncTask, rc *model.RemoteCluster) {
if task.retryMsg != nil && len(task.retryMsg.Statuses) == 1 && task.retryMsg.Statuses[0].UserId == userId {
// this was a retry for specific status that failed previously. Try again if within MaxRetries.
if task.incRetry() {
scs.addTask(task)
} else {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "error syncing status",
mlog.String("remote", rc.DisplayName),
mlog.String("user_id", userId),
)
}
return
}
// this status failed as part of a group of statuses. Retry as an individual status.
status, err := scs.server.GetStore().Status().Get(userId)
if err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "error fetching status for sync retry",
mlog.String("remote", rc.DisplayName),
mlog.String("user_id", userId),
mlog.Err(err),
)
return
}
syncMsg := model.NewSyncMsg(task.channelID)
syncMsg.Statuses = []*model.Status{status}
scs.addTask(newSyncTask(task.channelID, task.userID, task.remoteID, nil, syncMsg))
}
// notifyRemoteOffline creates an ephemeral post to the author for any posts created recently to remotes
// that are offline.
func (scs *Service) notifyRemoteOffline(posts []*model.Post, rc *model.RemoteCluster) {
// only send one ephemeral post per author.
notified := make(map[string]bool)
// range the slice in reverse so the newest posts are visited first; this ensures an ephemeral
// get added where it is mostly likely to be seen.
for _, post := range slices.Backward(posts) {
if didNotify := notified[post.UserId]; didNotify {
continue
}
postCreateAt := model.GetTimeForMillis(post.CreateAt)
if post.DeleteAt == 0 && post.UserId != "" && time.Since(postCreateAt) < NotifyRemoteOfflineThreshold {
T := scs.getUserTranslations(post.UserId)
ephemeral := &model.Post{
ChannelId: post.ChannelId,
Message: T("sharedchannel.cannot_deliver_post", map[string]any{"Remote": rc.DisplayName}),
CreateAt: post.CreateAt + 1,
}
scs.app.SendEphemeralPost(request.EmptyContext(scs.server.Log()), post.UserId, ephemeral)
notified[post.UserId] = true
}
}
}
func (scs *Service) updateCursorForRemote(scrId string, rc *model.RemoteCluster, cursor model.GetPostsSinceForSyncCursor) {
if err := scs.server.GetStore().SharedChannel().UpdateRemoteCursor(scrId, cursor); err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "error updating cursor for shared channel remote",
mlog.String("remote", rc.DisplayName),
mlog.Err(err),
)
return
}
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "updated cursor for remote",
mlog.String("remote_id", rc.RemoteId),
mlog.String("remote", rc.DisplayName),
mlog.Int("last_post_create_at", cursor.LastPostCreateAt),
mlog.String("last_post_create_id", cursor.LastPostCreateID),
mlog.Int("last_post_update_at", cursor.LastPostUpdateAt),
mlog.String("last_post_update_id", cursor.LastPostUpdateID),
)
}
func (scs *Service) getUserTranslations(userId string) i18n.TranslateFunc {
var locale string
user, err := scs.server.GetStore().User().Get(request.EmptyContext(scs.server.Log()), userId)
if err == nil {
locale = user.Locale
}
if locale == "" {
locale = model.DefaultLocale
}
return i18n.GetUserTranslations(locale)
}
// shouldUserSync determines if a user needs to be synchronized.
// User should be synchronized if it has no entry in the SharedChannelUsers table for the specified channel,
// or there is an entry but the LastSyncAt is less than user.UpdateAt
func (scs *Service) shouldUserSync(user *model.User, channelID string, rc *model.RemoteCluster) (sync bool, syncImage bool, err error) {
// don't sync users with the remote they originated from.
if user.RemoteId != nil && *user.RemoteId == rc.RemoteId {
return false, false, nil
}
scu, err := scs.server.GetStore().SharedChannel().GetSingleUser(user.Id, channelID, rc.RemoteId)
if err != nil {
if _, ok := err.(errNotFound); !ok {
return false, false, err
}
// user not in the SharedChannelUsers table, so we must add them.
scu = &model.SharedChannelUser{
UserId: user.Id,
RemoteId: rc.RemoteId,
ChannelId: channelID,
}
if _, err = scs.server.GetStore().SharedChannel().SaveUser(scu); err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "Error adding user to shared channel users",
mlog.String("user_id", user.Id),
mlog.String("channel_id", channelID),
mlog.String("remote_id", rc.RemoteId),
mlog.Err(err),
)
} else {
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Added user to shared channel users",
mlog.String("user_id", user.Id),
mlog.String("channel_id", channelID),
mlog.String("remote_id", rc.RemoteId),
)
}
return true, true, nil
}
return user.UpdateAt > scu.LastSyncAt, user.LastPictureUpdate > scu.LastSyncAt, nil
}
func (scs *Service) syncProfileImage(user *model.User, channelID string, rc *model.RemoteCluster) {
rcs := scs.server.GetRemoteClusterService()
if rcs == nil {
return
}
if rc.IsPlugin() {
scs.sendProfileImageToPlugin(user, channelID, rc)
return
}
ctx, cancel := context.WithTimeout(context.Background(), ProfileImageSyncTimeout)
defer cancel()
rcs.SendProfileImage(ctx, user.Id, rc, scs.app, func(userId string, rc *model.RemoteCluster, resp *remotecluster.Response, err error) {
if resp.IsSuccess() {
scs.recordProfileImageSuccess(user.Id, channelID, rc.RemoteId)
return
}
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "Error synchronizing users profile image",
mlog.String("user_id", user.Id),
mlog.String("channel_id", channelID),
mlog.String("remote_id", rc.RemoteId),
mlog.Err(err),
)
})
}
func (scs *Service) sendProfileImageToPlugin(user *model.User, channelID string, rc *model.RemoteCluster) {
if err := scs.app.OnSharedChannelsProfileImageSyncMsg(user, rc); err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "Error synchronizing users profile image for plugin",
mlog.String("user_id", user.Id),
mlog.String("channel_id", channelID),
mlog.String("remote_id", rc.RemoteId),
mlog.Err(err),
)
}
scs.recordProfileImageSuccess(user.Id, channelID, rc.RemoteId)
}
func (scs *Service) recordProfileImageSuccess(userID, channelID, remoteID string) {
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Users profile image synchronized",
mlog.String("user_id", userID),
mlog.String("channel_id", channelID),
mlog.String("remote_id", remoteID),
)
// update LastSyncAt for user in SharedChannelUsers table
if err := scs.server.GetStore().SharedChannel().UpdateUserLastSyncAt(userID, channelID, remoteID); err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "Error updating users LastSyncTime after profile image update",
mlog.String("user_id", userID),
mlog.String("channel_id", channelID),
mlog.String("remote_id", remoteID),
mlog.Err(err),
)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,508 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sharedchannel
import (
"context"
"database/sql"
"errors"
"fmt"
"maps"
"sync"
"testing"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
"github.com/mattermost/mattermost/server/v8/platform/services/remotecluster"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
func newTestService() *Service {
return &Service{
changeSignal: make(chan struct{}, 1),
tasks: make(map[string]syncTask),
}
}
func TestProcessTask_RemoteClusterLookup(t *testing.T) {
// Mirror how the store wraps the not-found case for a soft-deleted/missing remote.
notFound := fmt.Errorf("failed to find RemoteCluster: %w", sql.ErrNoRows)
newService := func(t *testing.T, remoteID string, rc *model.RemoteCluster, getErr error) (*Service, *mocks.SharedChannelStore) {
t.Helper()
mockRemoteClusterStore := &mocks.RemoteClusterStore{}
mockRemoteClusterStore.On("Get", remoteID, false).Return(rc, getErr)
mockSharedChannelStore := &mocks.SharedChannelStore{}
mockStore := &mocks.Store{}
mockStore.On("RemoteCluster").Return(mockRemoteClusterStore)
mockStore.On("SharedChannel").Return(mockSharedChannelStore)
mockServer := &MockServerIface{}
mockServer.On("GetStore").Return(mockStore)
mockServer.On("GetMetrics").Return(nil)
mockServer.On("Log").Return(mlog.CreateConsoleTestLogger(t))
return &Service{
server: mockServer,
changeSignal: make(chan struct{}, 1),
tasks: make(map[string]syncTask),
}, mockSharedChannelStore
}
t.Run("orphaned remote self-heals by soft-deleting the live SCR row", func(t *testing.T) {
channelID := model.NewId()
remoteID := model.NewId()
scr := &model.SharedChannelRemote{Id: model.NewId(), ChannelId: channelID, RemoteId: remoteID, DeleteAt: 0}
scs, scStore := newService(t, remoteID, nil, notFound)
scStore.On("GetRemoteByIds", channelID, remoteID).Return(scr, nil)
scStore.On("DeleteRemote", scr.Id).Return(true, nil)
err := scs.processTask(newSyncTask(channelID, "", remoteID, nil, nil))
require.NoError(t, err, "an orphaned remote should be skipped, not surfaced as an error")
scStore.AssertNumberOfCalls(t, "DeleteRemote", 1)
scStore.AssertCalled(t, "DeleteRemote", scr.Id)
})
t.Run("self-healed orphan is drained from the sync queue and not retried", func(t *testing.T) {
channelID := model.NewId()
remoteID := model.NewId()
scr := &model.SharedChannelRemote{Id: model.NewId(), ChannelId: channelID, RemoteId: remoteID, DeleteAt: 0}
scs, scStore := newService(t, remoteID, nil, notFound)
scStore.On("GetRemoteByIds", channelID, remoteID).Return(scr, nil)
scStore.On("DeleteRemote", scr.Id).Return(true, nil)
task := newSyncTask(channelID, "", remoteID, nil, nil)
scs.addTask(task)
scs.doSync()
assert.Empty(t, scs.tasks, "the orphaned task must be drained, not re-enqueued for retry")
scStore.AssertNumberOfCalls(t, "DeleteRemote", 1)
})
t.Run("already soft-deleted SCR row is not deleted again", func(t *testing.T) {
channelID := model.NewId()
remoteID := model.NewId()
scr := &model.SharedChannelRemote{Id: model.NewId(), ChannelId: channelID, RemoteId: remoteID, DeleteAt: model.GetMillis()}
scs, scStore := newService(t, remoteID, nil, notFound)
scStore.On("GetRemoteByIds", channelID, remoteID).Return(scr, nil)
err := scs.processTask(newSyncTask(channelID, "", remoteID, nil, nil))
require.NoError(t, err)
scStore.AssertNotCalled(t, "DeleteRemote", mock.Anything)
})
t.Run("missing SCR row is skipped without error or delete", func(t *testing.T) {
channelID := model.NewId()
remoteID := model.NewId()
scs, scStore := newService(t, remoteID, nil, notFound)
scStore.On("GetRemoteByIds", channelID, remoteID).Return(nil, store.NewErrNotFound("SharedChannelRemote", "missing"))
err := scs.processTask(newSyncTask(channelID, "", remoteID, nil, nil))
require.NoError(t, err)
scStore.AssertNotCalled(t, "DeleteRemote", mock.Anything)
})
t.Run("failure to soft-delete the orphan is swallowed without retry", func(t *testing.T) {
channelID := model.NewId()
remoteID := model.NewId()
scr := &model.SharedChannelRemote{Id: model.NewId(), ChannelId: channelID, RemoteId: remoteID, DeleteAt: 0}
scs, scStore := newService(t, remoteID, nil, notFound)
scStore.On("GetRemoteByIds", channelID, remoteID).Return(scr, nil)
scStore.On("DeleteRemote", scr.Id).Return(false, errors.New("db is down"))
err := scs.processTask(newSyncTask(channelID, "", remoteID, nil, nil))
require.NoError(t, err, "a self-heal delete failure must not surface as a sync error or trigger a retry")
scStore.AssertCalled(t, "DeleteRemote", scr.Id)
})
t.Run("transient error is propagated for retry", func(t *testing.T) {
remoteID := model.NewId()
dbErr := errors.New("write tcp: connection reset by peer")
scs, scStore := newService(t, remoteID, nil, dbErr)
err := scs.processTask(newSyncTask("channel-1", "", remoteID, nil, nil))
require.Error(t, err, "a transient lookup error must still be returned so the task retries")
assert.ErrorContains(t, err, "connection reset by peer")
scStore.AssertNotCalled(t, "GetRemoteByIds", mock.Anything, mock.Anything)
scStore.AssertNotCalled(t, "DeleteRemote", mock.Anything)
})
}
func TestAddTask_OriginRemoteIDMerge(t *testing.T) {
tests := []struct {
name string
firstOrigin string
secondOrigin string
expectedOrigin string
}{
{
name: "same remote origin is preserved",
firstOrigin: "remote-A",
secondOrigin: "remote-A",
expectedOrigin: "remote-A",
},
{
name: "local then remote clears origin",
firstOrigin: "",
secondOrigin: "remote-A",
expectedOrigin: "",
},
{
name: "remote then local clears origin",
firstOrigin: "remote-A",
secondOrigin: "",
expectedOrigin: "",
},
{
name: "different remotes clears origin",
firstOrigin: "remote-A",
secondOrigin: "remote-B",
expectedOrigin: "",
},
{
name: "both local stays empty",
firstOrigin: "",
secondOrigin: "",
expectedOrigin: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
scs := newTestService()
channelID := "channel-1"
first := newSyncTask(channelID, "", "", nil, nil)
first.originRemoteID = tc.firstOrigin
scs.addTask(first)
second := newSyncTask(channelID, "", "", nil, nil)
second.originRemoteID = tc.secondOrigin
scs.addTask(second)
merged, ok := scs.tasks[first.id]
require.True(t, ok, "task should exist")
assert.Equal(t, tc.expectedOrigin, merged.originRemoteID)
})
}
}
// mockRCSForSync is a minimal no-op implementation of RemoteClusterServiceIFace that
// records NotifySyncFailed calls. By default its SendMsg simulates an unreachable remote
// (delivery failure reported via the callback); set cbResp/cbErr to instead deliver a
// specific response envelope (e.g. an unparseable or empty payload with no transport error).
type mockRCSForSync struct {
mu sync.Mutex
notifyCalls []string
cbConfigured bool
cbResp *remotecluster.Response
cbErr error
enqueueErr error // when set, SendMsg returns this WITHOUT invoking the callback
}
func (m *mockRCSForSync) NotifySyncFailed(remoteId string) {
m.mu.Lock()
defer m.mu.Unlock()
m.notifyCalls = append(m.notifyCalls, remoteId)
}
func (m *mockRCSForSync) Shutdown() error { return nil }
func (m *mockRCSForSync) Start() error { return nil }
func (m *mockRCSForSync) Active() bool { return true }
func (m *mockRCSForSync) AddTopicListener(topic string, listener remotecluster.TopicListener) string {
return ""
}
func (m *mockRCSForSync) RemoveTopicListener(listenerId string) {}
func (m *mockRCSForSync) AddConnectionStateListener(listener remotecluster.ConnectionStateListener) string {
return ""
}
func (m *mockRCSForSync) RemoveConnectionStateListener(listenerId string) {}
func (m *mockRCSForSync) SendMsg(_ context.Context, msg model.RemoteClusterMsg, rc *model.RemoteCluster, f remotecluster.SendMsgResultFunc) error {
// Simulate an enqueue failure (e.g. BufferFullError): the callback never runs.
if m.enqueueErr != nil {
return m.enqueueErr
}
// The enqueue succeeds (return nil); the outcome is reported asynchronously via the
// callback, mirroring how remotecluster.Service.sendMsg surfaces a send result.
if f != nil {
if m.cbConfigured {
f(msg, rc, m.cbResp, m.cbErr)
} else {
// Default: simulate an unreachable remote (transport error).
f(msg, rc, &remotecluster.Response{Err: "remote unreachable"}, errors.New("remote unreachable"))
}
}
return nil
}
func (m *mockRCSForSync) SendFile(_ context.Context, _ *model.UploadSession, _ *model.FileInfo, _ *model.RemoteCluster, _ remotecluster.ReaderProvider, _ remotecluster.SendFileResultFunc) error {
return errors.New("remote unreachable")
}
func (m *mockRCSForSync) SendProfileImage(_ context.Context, _ string, _ *model.RemoteCluster, _ remotecluster.ProfileImageProvider, _ remotecluster.SendProfileImageResultFunc) error {
return errors.New("remote unreachable")
}
func (m *mockRCSForSync) AcceptInvitation(_ *model.RemoteClusterInvite, _, _, _, _, _ string) (*model.RemoteCluster, error) {
return nil, nil
}
func (m *mockRCSForSync) ReceiveIncomingMsg(_ *model.RemoteCluster, _ model.RemoteClusterMsg) remotecluster.Response {
return remotecluster.Response{}
}
func (m *mockRCSForSync) ReceiveInviteConfirmation(_ model.RemoteClusterInvite) (*model.RemoteCluster, error) {
return nil, nil
}
func (m *mockRCSForSync) PingNow(_ *model.RemoteCluster) {}
var _ remotecluster.RemoteClusterServiceIFace = (*mockRCSForSync)(nil)
// TestProcessTask_RetryDelay verifies that a failed per-remote sync re-enqueues the task
// with a schedule at least SyncRetryDelay in the future.
func TestProcessTask_RetryDelay(t *testing.T) {
channelID := model.NewId()
remoteID := model.NewId()
rc := &model.RemoteCluster{
RemoteId: remoteID,
DisplayName: "test-remote",
LastPingAt: model.GetMillis(), // online
}
rcStoreMock := &mocks.RemoteClusterStore{}
rcStoreMock.On("Get", remoteID, false).Return(rc, nil)
mockStore := &mocks.Store{}
mockStore.On("RemoteCluster").Return(rcStoreMock)
mockServer := &MockServerIface{}
mockServer.On("GetStore").Return(mockStore)
mockServer.On("GetMetrics").Return(nil)
mockServer.On("GetRemoteClusterService").Return(nil) // nil rcs causes syncForRemote to fail immediately
scs := &Service{
server: mockServer,
changeSignal: make(chan struct{}, 1),
tasks: make(map[string]syncTask),
}
task := newSyncTask(channelID, "", remoteID, nil, nil)
scs.addTask(task)
before := time.Now()
scs.doSync()
scs.mux.RLock()
tasksCopy := make(map[string]syncTask, len(scs.tasks))
maps.Copy(tasksCopy, scs.tasks)
scs.mux.RUnlock()
require.Len(t, tasksCopy, 1, "failed task should be re-enqueued for retry")
for _, retried := range tasksCopy {
assert.Equal(t, 1, retried.retryCount, "retry count should be 1 after first failure")
assert.True(t, retried.schedule.After(before.Add(SyncRetryDelay)),
"re-enqueued task schedule should be at least SyncRetryDelay in the future (got %s, want after %s)",
retried.schedule, before.Add(SyncRetryDelay))
}
}
// TestProcessTask_FanOutRetryPerRemote verifies that when a task with no specific remote
// fans out to several remotes and each fails, every remote keeps its own retry task (rather
// than all but one being discarded by addTask's merge on the shared task id).
func TestProcessTask_FanOutRetryPerRemote(t *testing.T) {
channelID := model.NewId()
rcA := &model.RemoteCluster{RemoteId: model.NewId(), DisplayName: "remote-A"}
rcB := &model.RemoteCluster{RemoteId: model.NewId(), DisplayName: "remote-B"}
rcStoreMock := &mocks.RemoteClusterStore{}
// Both the in-channel and auto-invited lookups return the two remotes; remotesMap dedups.
rcStoreMock.On("GetAll", 0, 999999, mock.Anything).Return([]*model.RemoteCluster{rcA, rcB}, nil)
mockStore := &mocks.Store{}
mockStore.On("RemoteCluster").Return(rcStoreMock)
mockServer := &MockServerIface{}
mockServer.On("GetStore").Return(mockStore)
mockServer.On("GetMetrics").Return(nil)
mockServer.On("GetRemoteClusterService").Return(nil) // nil rcs makes syncForRemote fail for each remote
scs := &Service{
server: mockServer,
changeSignal: make(chan struct{}, 1),
tasks: make(map[string]syncTask),
}
// Fan-out task: empty remoteID.
scs.addTask(newSyncTask(channelID, "", "", nil, nil))
scs.doSync()
scs.mux.RLock()
tasksCopy := make(map[string]syncTask, len(scs.tasks))
maps.Copy(tasksCopy, scs.tasks)
scs.mux.RUnlock()
require.Len(t, tasksCopy, 2, "each failed remote should retain its own retry task")
gotRemotes := make(map[string]bool)
for _, retried := range tasksCopy {
assert.Equal(t, 1, retried.retryCount, "retry count should be 1 after first failure")
gotRemotes[retried.remoteID] = true
}
assert.True(t, gotRemotes[rcA.RemoteId], "remote A should have its own retry task")
assert.True(t, gotRemotes[rcB.RemoteId], "remote B should have its own retry task")
}
// TestSendSyncMsgToRemote_NotifiesOnDeliveryFailure guards the offline-recovery fix: when a
// send fails asynchronously (remote unreachable), sendSyncMsgToRemote does NOT surface an
// error to the caller — the send is fire-and-forget and the cursor is left un-advanced — but
// it signals the remote cluster service via NotifySyncFailed so the next successful ping
// drives a ForceSyncForRemote recovery.
func TestSendSyncMsgToRemote_NotifiesOnDeliveryFailure(t *testing.T) {
mockRCS := &mockRCSForSync{}
mockServer := &MockServerIface{}
mockServer.On("GetRemoteClusterService").Return(mockRCS)
mockServer.On("Log").Return(mlog.CreateConsoleTestLogger(t))
scs := &Service{
server: mockServer,
changeSignal: make(chan struct{}, 1),
tasks: make(map[string]syncTask),
}
remoteID := model.NewId()
rc := &model.RemoteCluster{RemoteId: remoteID, Name: "test-remote", SiteURL: "http://example.invalid"}
msg := model.NewSyncMsg(model.NewId())
var callbackInvoked bool
err := scs.sendSyncMsgToRemote(msg, rc, func(model.SyncResponse, error) { callbackInvoked = true })
require.NoError(t, err, "a delivery failure must not be surfaced as an error; the send is fire-and-forget")
assert.False(t, callbackInvoked, "the result callback must not run on a delivery failure, so the cursor stays un-advanced")
mockRCS.mu.Lock()
notifyCalls := mockRCS.notifyCalls
mockRCS.mu.Unlock()
require.Len(t, notifyCalls, 1, "NotifySyncFailed should be called once on delivery failure")
assert.Equal(t, remoteID, notifyCalls[0])
}
// TestSendSyncMsgToRemote_NotifiesOnUnconfirmedResponse covers the case where the send
// succeeds at the transport level (no errResp) but the response envelope can't be treated
// as a confirmed sync: an unparseable payload, or an empty payload (the receive side always
// sets a SyncResponse payload on success). In both cases sendSyncMsgToRemote must not error,
// must not advance the cursor, and must signal NotifySyncFailed for a ping-driven resync.
func TestSendSyncMsgToRemote_NotifiesOnUnconfirmedResponse(t *testing.T) {
cases := []struct {
name string
resp *remotecluster.Response
}{
{name: "unparseable payload", resp: &remotecluster.Response{Status: model.StatusOk, Payload: []byte("not json")}},
{name: "empty payload", resp: &remotecluster.Response{Status: model.StatusOk}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
mockRCS := &mockRCSForSync{cbConfigured: true, cbResp: tc.resp, cbErr: nil}
mockServer := &MockServerIface{}
mockServer.On("GetRemoteClusterService").Return(mockRCS)
mockServer.On("Log").Return(mlog.CreateConsoleTestLogger(t))
scs := &Service{
server: mockServer,
changeSignal: make(chan struct{}, 1),
tasks: make(map[string]syncTask),
}
remoteID := model.NewId()
rc := &model.RemoteCluster{RemoteId: remoteID, Name: "test-remote", SiteURL: "http://example.invalid"}
msg := model.NewSyncMsg(model.NewId())
var callbackInvoked bool
err := scs.sendSyncMsgToRemote(msg, rc, func(model.SyncResponse, error) { callbackInvoked = true })
require.NoError(t, err, "an unconfirmed response must not be surfaced as an error")
assert.False(t, callbackInvoked, "the result callback must not run for an unconfirmed response, so the cursor stays un-advanced")
mockRCS.mu.Lock()
notifyCalls := mockRCS.notifyCalls
mockRCS.mu.Unlock()
require.Len(t, notifyCalls, 1, "NotifySyncFailed should be called once for an unconfirmed response")
assert.Equal(t, remoteID, notifyCalls[0])
})
}
}
// TestSendSyncMsgToRemote_EnqueueErrorDoesNotHang verifies that when SendMsg fails to enqueue
// (e.g. BufferFullError) — so the result callback never runs — sendSyncMsgToRemote returns the
// error instead of blocking forever on the callback's WaitGroup, which would freeze the single
// doSync goroutine and stall all shared-channel sync.
func TestSendSyncMsgToRemote_EnqueueErrorDoesNotHang(t *testing.T) {
mockRCS := &mockRCSForSync{enqueueErr: errors.New("buffer full")}
mockServer := &MockServerIface{}
mockServer.On("GetRemoteClusterService").Return(mockRCS)
mockServer.On("Log").Return(mlog.CreateConsoleTestLogger(t))
scs := &Service{
server: mockServer,
changeSignal: make(chan struct{}, 1),
tasks: make(map[string]syncTask),
}
rc := &model.RemoteCluster{RemoteId: model.NewId(), Name: "test-remote", SiteURL: "http://example.invalid"}
msg := model.NewSyncMsg(model.NewId())
// Guard with a timeout so a regression (hang on wg.Wait) fails fast instead of blocking
// the whole suite until the test binary times out.
done := make(chan error, 1)
go func() { done <- scs.sendSyncMsgToRemote(msg, rc, nil) }()
select {
case err := <-done:
require.Error(t, err, "an enqueue failure must be returned to the caller")
assert.ErrorContains(t, err, "buffer full")
case <-time.After(5 * time.Second):
require.Fail(t, "sendSyncMsgToRemote hung on wg.Wait() after an enqueue failure")
}
// The send never left the queue, so there is no delivery outcome to notify about; that
// path is the caller's retry (the returned error), not a ping-driven resync.
mockRCS.mu.Lock()
notifyCount := len(mockRCS.notifyCalls)
mockRCS.mu.Unlock()
assert.Zero(t, notifyCount, "no sync-failed notification should fire when the enqueue itself failed")
}
func TestStripSharedChannelStatePostsForSync(t *testing.T) {
sd := &syncData{
posts: []*model.Post{
{Id: "state-1", Type: model.PostTypeSharedChannelState, ChannelId: "ch1", Message: "ignored"},
{Id: "user-1", Type: model.PostTypeDefault, ChannelId: "ch1", Message: "hello"},
},
}
stripSharedChannelStatePostsForSync(sd)
require.Len(t, sd.posts, 1)
assert.Equal(t, "user-1", sd.posts[0].Id)
assert.Equal(t, model.PostTypeDefault, sd.posts[0].Type)
}

View File

@@ -0,0 +1,139 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sharedchannel
import (
"errors"
"fmt"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
// fixMention transforms @username:remotename mentions to @username format
// Used when syncing posts to a user's home cluster
func fixMention(post *model.Post, mentionMap model.UserMentionMap, user *model.User) {
if post == nil || len(mentionMap) == 0 {
return
}
realUsername, ok := user.GetProp(model.UserPropsKeyRemoteUsername)
if !ok {
return
}
for mention, id := range mentionMap {
// Only process mentions with colons that match this user's ID
if id == user.Id && strings.Contains(mention, ":") {
post.Message = strings.ReplaceAll(post.Message, "@"+mention, "@"+realUsername)
}
}
}
func sanitizeUserForSync(user *model.User) *model.User {
user.Password = model.NewId()
user.AuthData = nil
user.AuthService = ""
user.Roles = "system_user"
user.AllowMarketing = false
user.NotifyProps = model.StringMap{}
user.LastPasswordUpdate = 0
user.LastPictureUpdate = 0
user.FailedAttempts = 0
user.MfaActive = false
user.MfaSecret = ""
return user
}
const MungUsernameSeparator = "-"
// mungUsername creates a new username by combining username and remote cluster name, plus
// a suffix to create uniqueness. If the resulting username exceeds the max length then
// it is truncated and ellipses added.
func mungUsername(username string, remotename string, suffix string, maxLen int) string {
if suffix != "" {
suffix = MungUsernameSeparator + suffix
}
// If the username already contains a colon then another server already munged it.
// In that case we can split on the colon and use the existing remote name.
// We still need to re-mung with suffix in case of collision.
comps := strings.Split(username, ":")
if len(comps) >= 2 {
username = comps[0]
remotename = strings.Join(comps[1:], "")
}
var userEllipses string
var remoteEllipses string
// The remotename is allowed to use up to half the maxLen, and the username gets the remaining space.
// Username might have a suffix to account for, and remotename always has a preceding colon.
half := maxLen / 2
// If the remotename is less than half the maxLen, then the left over space can be given to
// the username.
extra := max(half-(len(remotename)+1), 0)
truncUser := (len(username) + len(suffix)) - (half + extra)
if truncUser > 0 {
username = username[:len(username)-truncUser-3]
userEllipses = "..."
}
truncRemote := (len(remotename) + 1) - (maxLen - (len(username) + len(userEllipses) + len(suffix)))
if truncRemote > 0 {
remotename = remotename[:len(remotename)-truncRemote-3]
remoteEllipses = "..."
}
return fmt.Sprintf("%s%s%s:%s%s", username, suffix, userEllipses, remotename, remoteEllipses)
}
func isConflictError(err error) (string, bool) {
if err == nil {
return "", false
}
var errConflict *store.ErrConflict
if errors.As(err, &errConflict) {
return strings.ToLower(errConflict.Resource), true
}
var errInput *store.ErrInvalidInput
if errors.As(err, &errInput) {
_, field, _ := errInput.InvalidInputInfo()
return strings.ToLower(field), true
}
return "", false
}
func isNotFoundError(err error) bool {
if err == nil {
return false
}
var errNotFound *store.ErrNotFound
return errors.As(err, &errNotFound)
}
func postsSliceToMap(posts []*model.Post) map[string]*model.Post {
m := make(map[string]*model.Post, len(posts))
for _, p := range posts {
m[p.Id] = p
}
return m
}
func reducePostsSliceInCache(posts []*model.Post, cache map[string]*model.Post) []*model.Post {
reduced := make([]*model.Post, 0, len(posts))
for _, p := range posts {
if _, ok := cache[p.Id]; !ok {
reduced = append(reduced, p)
}
}
return reduced
}

View File

@@ -0,0 +1,250 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sharedchannel
import (
"strings"
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/stretchr/testify/require"
)
func Test_mungUsername(t *testing.T) {
type args struct {
username string
remotename string
suffix string
maxLen int
}
tests := []struct {
name string
args args
want string
}{
{"everything empty", args{username: "", remotename: "", suffix: "", maxLen: 64}, ":"},
{"no trunc, no suffix", args{username: "bart", remotename: "example.com", suffix: "", maxLen: 64}, "bart:example.com"},
{"no trunc, suffix", args{username: "bart", remotename: "example.com", suffix: "2", maxLen: 64}, "bart-2:example.com"},
{"trunc remote, no suffix", args{username: "bart", remotename: "example1234567890.com", suffix: "", maxLen: 24}, "bart:example123456789..."},
{"trunc remote, suffix", args{username: "bart", remotename: "example1234567890.com", suffix: "2", maxLen: 24}, "bart-2:example1234567..."},
{"trunc both, no suffix", args{username: R(24, "A"), remotename: R(24, "B"), suffix: "", maxLen: 24}, "AAAAAAAAA...:BBBBBBBB..."},
{"trunc both, suffix", args{username: R(24, "A"), remotename: R(24, "B"), suffix: "10", maxLen: 24}, "AAAAAA-10...:BBBBBBBB..."},
{"trunc user, no suffix", args{username: R(40, "A"), remotename: "abc", suffix: "", maxLen: 24}, "AAAAAAAAAAAAAAAAA...:abc"},
{"trunc user, suffix", args{username: R(40, "A"), remotename: "abc", suffix: "11", maxLen: 24}, "AAAAAAAAAAAAAA-11...:abc"},
{"trunc user, remote, no suffix", args{username: R(40, "A"), remotename: "abcdefghijk", suffix: "", maxLen: 24}, "AAAAAAAAA...:abcdefghijk"},
{"trunc user, remote, suffix", args{username: R(40, "A"), remotename: "abcdefghijk", suffix: "19", maxLen: 24}, "AAAAAA-19...:abcdefghijk"},
{"short user, long remote, no suffix", args{username: "bart", remotename: R(40, "B"), suffix: "", maxLen: 24}, "bart:BBBBBBBBBBBBBBBB..."},
{"long user, short remote, no suffix", args{username: R(40, "A"), remotename: "abc.com", suffix: "", maxLen: 24}, "AAAAAAAAAAAAA...:abc.com"},
{"short user, long remote, suffix", args{username: "bart", remotename: R(40, "B"), suffix: "12", maxLen: 24}, "bart-12:BBBBBBBBBBBBB..."},
{"long user, short remote, suffix", args{username: R(40, "A"), remotename: "abc.com", suffix: "12", maxLen: 24}, "AAAAAAAAAA-12...:abc.com"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := mungUsername(tt.args.username, tt.args.remotename, tt.args.suffix, tt.args.maxLen); got != tt.want {
t.Errorf("mungUsername() = %v, want %v", got, tt.want)
}
})
}
}
func Test_mungUsernameFuzz(t *testing.T) {
// ensure no index out of bounds panic for any combination
for i := range 70 {
for j := range 70 {
for k := range 3 {
username := R(i, "A")
remotename := R(j, "B")
suffix := R(k, "1")
result := mungUsername(username, remotename, suffix, 64)
require.LessOrEqual(t, len(result), 64)
}
}
}
}
func Test_fixMention(t *testing.T) {
tests := []struct {
name string
post *model.Post
mentionMap model.UserMentionMap
user *model.User
expected string
}{
{
name: "nil post",
post: nil,
mentionMap: model.UserMentionMap{"user:remote": "userid"},
user: &model.User{Id: "userid"},
expected: "",
},
{
name: "empty mentionMap",
post: &model.Post{Message: "hello @user:remote"},
mentionMap: model.UserMentionMap{},
user: &model.User{Id: "userid"},
expected: "hello @user:remote",
},
{
name: "no RemoteUsername prop",
post: &model.Post{Message: "hello @user:remote"},
mentionMap: model.UserMentionMap{"user:remote": "userid"},
user: &model.User{Id: "userid"},
expected: "hello @user:remote",
},
// Username clash test cases (same username on both local and remote)
// With the new behavior, when syncing to a user's home cluster, we always remove the remote suffix
{
name: "username clash - simple mention",
post: &model.Post{Message: "hello @user:remote"},
mentionMap: model.UserMentionMap{"user:remote": "userid"},
user: &model.User{Id: "userid", RemoteId: new("remoteid"), Props: model.StringMap{model.UserPropsKeyRemoteUsername: "user"}},
expected: "hello @user",
},
{
name: "username clash - mention at start",
post: &model.Post{Message: "@user:remote hello"},
mentionMap: model.UserMentionMap{"user:remote": "userid"},
user: &model.User{Id: "userid", RemoteId: new("remoteid"), Props: model.StringMap{model.UserPropsKeyRemoteUsername: "user"}},
expected: "@user hello",
},
{
name: "username clash - multiple mentions",
post: &model.Post{Message: "hello @user:remote and @user:remote again"},
mentionMap: model.UserMentionMap{"user:remote": "userid"},
user: &model.User{Id: "userid", RemoteId: new("remoteid"), Props: model.StringMap{model.UserPropsKeyRemoteUsername: "user"}},
expected: "hello @user and @user again",
},
{
name: "simple mention different username",
post: &model.Post{Message: "hello @user:remote"},
mentionMap: model.UserMentionMap{"user:remote": "userid"},
user: &model.User{Id: "userid", RemoteId: new("remoteid"), Props: model.StringMap{model.UserPropsKeyRemoteUsername: "realuser"}},
expected: "hello @realuser",
},
{
name: "simple mention same username",
post: &model.Post{Message: "hello @user:remote"},
mentionMap: model.UserMentionMap{"user:remote": "userid"},
user: &model.User{Id: "userid", RemoteId: new("remoteid"), Props: model.StringMap{model.UserPropsKeyRemoteUsername: "user"}},
expected: "hello @user",
},
{
name: "mention at start",
post: &model.Post{Message: "@user:remote hello"},
mentionMap: model.UserMentionMap{"user:remote": "userid"},
user: &model.User{Id: "userid", RemoteId: new("remoteid"), Props: model.StringMap{model.UserPropsKeyRemoteUsername: "realuser"}},
expected: "@realuser hello",
},
{
name: "mention at end",
post: &model.Post{Message: "hello @user:remote"},
mentionMap: model.UserMentionMap{"user:remote": "userid"},
user: &model.User{Id: "userid", RemoteId: new("remoteid"), Props: model.StringMap{model.UserPropsKeyRemoteUsername: "realuser"}},
expected: "hello @realuser",
},
{
name: "multiple mentions of same user",
post: &model.Post{Message: "hello @user:remote and @user:remote again"},
mentionMap: model.UserMentionMap{"user:remote": "userid"},
user: &model.User{Id: "userid", RemoteId: new("remoteid"), Props: model.StringMap{model.UserPropsKeyRemoteUsername: "realuser"}},
expected: "hello @realuser and @realuser again",
},
{
name: "multiple different mentions",
post: &model.Post{Message: "hello @user:remote and @other:remote"},
mentionMap: model.UserMentionMap{"user:remote": "userid", "other:remote": "otherid"},
user: &model.User{Id: "userid", RemoteId: new("remoteid"), Props: model.StringMap{model.UserPropsKeyRemoteUsername: "realuser"}},
expected: "hello @realuser and @other:remote",
},
{
name: "mention without colon",
post: &model.Post{Message: "hello @user"},
mentionMap: model.UserMentionMap{"user": "userid"},
user: &model.User{Id: "userid", RemoteId: new("remoteid"), Props: model.StringMap{model.UserPropsKeyRemoteUsername: "realuser"}},
expected: "hello @user",
},
{
name: "mention different user id",
post: &model.Post{Message: "hello @user:remote"},
mentionMap: model.UserMentionMap{"user:remote": "different"},
user: &model.User{Id: "userid", RemoteId: new("remoteid"), Props: model.StringMap{model.UserPropsKeyRemoteUsername: "realuser"}},
expected: "hello @user:remote",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fixMention(tt.post, tt.mentionMap, tt.user)
if tt.post != nil {
require.Equal(t, tt.expected, tt.post.Message)
}
})
}
}
// R returns a string with the specified string repeated `count` times.
func R(count int, s string) string {
return strings.Repeat(s, count)
}
func TestHandleUserMentions(t *testing.T) {
t.Run("should format mentions across clusters", func(t *testing.T) {
// This test verifies the behavior when same username exists on multiple clusters
// Define remote cluster names (not server names)
clusterBName := "remote_cluster_B"
// Create user with admin username on remote cluster B
adminUserB := &model.User{
Id: "adminB",
Username: "admin",
RemoteId: new(clusterBName),
Props: model.StringMap{model.UserPropsKeyRemoteUsername: "admin"},
}
// Scenario: User on Cluster B mentions local admin
// The mention should show as @admin on Cluster B
postOnClusterB := &model.Post{
Message: "Hey @admin, please review this.",
}
// Mentions are preserved when synced between clusters
// so that the local admin on Cluster A would receive a notification
// We're verifying the post will keep its mention format after being synced
require.Contains(t, postOnClusterB.Message, "@admin",
"Message should preserve @admin mention when synced between clusters")
// Scenario: User on Cluster A explicitly mentions admin on Cluster B
// It will show as @admin:remote_cluster_B on Cluster A
postWithRemoteMention := &model.Post{
Message: "Let's ask @admin:remote_cluster_B about this",
}
mentionMapWithRemote := model.UserMentionMap{
"admin:remote_cluster_B": "adminB",
}
// When this post is synced to Cluster B, fixMention will transform
// @admin:remote_cluster_B to @admin (removing the remote suffix)
postCopy := &model.Post{Message: postWithRemoteMention.Message}
fixMention(postCopy, mentionMapWithRemote, adminUserB)
// fixMention should replace @username:remote with @realusername (without the remote suffix)
// when syncing to the user's home cluster
expected := "Let's ask @admin about this"
// The part we're testing is that mentions are simplified when sent to the user's home cluster
require.Equal(t, expected, postCopy.Message,
"Should remove remote cluster suffix when message is viewed on user's home server")
})
}