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
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:
12
server/platform/services/sharedchannel/.mockery.yaml
Normal file
12
server/platform/services/sharedchannel/.mockery.yaml
Normal 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:
|
||||
519
server/platform/services/sharedchannel/README.md
Normal file
519
server/platform/services/sharedchannel/README.md
Normal 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.
|
||||
210
server/platform/services/sharedchannel/attachment.go
Normal file
210
server/platform/services/sharedchannel/attachment.go
Normal 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
|
||||
}
|
||||
601
server/platform/services/sharedchannel/channelinvite.go
Normal file
601
server/platform/services/sharedchannel/channelinvite.go
Normal 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
|
||||
}
|
||||
527
server/platform/services/sharedchannel/channelinvite_test.go
Normal file
527
server/platform/services/sharedchannel/channelinvite_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
57
server/platform/services/sharedchannel/membership.go
Normal file
57
server/platform/services/sharedchannel/membership.go
Normal 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)
|
||||
}
|
||||
}
|
||||
193
server/platform/services/sharedchannel/membership_recv.go
Normal file
193
server/platform/services/sharedchannel/membership_recv.go
Normal 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
|
||||
}
|
||||
397
server/platform/services/sharedchannel/membership_recv_test.go
Normal file
397
server/platform/services/sharedchannel/membership_recv_test.go
Normal 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)")
|
||||
}
|
||||
391
server/platform/services/sharedchannel/membership_send_test.go
Normal file
391
server/platform/services/sharedchannel/membership_send_test.go
Normal 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, ¬FoundError{})
|
||||
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, ¬FoundError{})
|
||||
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, ¬FoundError{})
|
||||
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, ¬FoundError{})
|
||||
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, ¬FoundError{})
|
||||
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, ¬FoundError{})
|
||||
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 }
|
||||
857
server/platform/services/sharedchannel/mock_AppIface_test.go
Normal file
857
server/platform/services/sharedchannel/mock_AppIface_test.go
Normal 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
|
||||
}
|
||||
174
server/platform/services/sharedchannel/mock_ServerIface_test.go
Normal file
174
server/platform/services/sharedchannel/mock_ServerIface_test.go
Normal 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
|
||||
}
|
||||
88
server/platform/services/sharedchannel/permalink.go
Normal file
88
server/platform/services/sharedchannel/permalink.go
Normal 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)
|
||||
})
|
||||
}
|
||||
113
server/platform/services/sharedchannel/permalink_test.go
Normal file
113
server/platform/services/sharedchannel/permalink_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
488
server/platform/services/sharedchannel/service.go
Normal file
488
server/platform/services/sharedchannel/service.go
Normal 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)
|
||||
}
|
||||
377
server/platform/services/sharedchannel/service_api.go
Normal file
377
server/platform/services/sharedchannel/service_api.go
Normal 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
|
||||
}
|
||||
324
server/platform/services/sharedchannel/service_api_test.go
Normal file
324
server/platform/services/sharedchannel/service_api_test.go
Normal 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)
|
||||
}
|
||||
118
server/platform/services/sharedchannel/service_test.go
Normal file
118
server/platform/services/sharedchannel/service_test.go
Normal 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)
|
||||
}
|
||||
854
server/platform/services/sharedchannel/sync_recv.go
Normal file
854
server/platform/services/sharedchannel/sync_recv.go
Normal 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)
|
||||
}
|
||||
}
|
||||
371
server/platform/services/sharedchannel/sync_recv_test.go
Normal file
371
server/platform/services/sharedchannel/sync_recv_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
748
server/platform/services/sharedchannel/sync_send.go
Normal file
748
server/platform/services/sharedchannel/sync_send.go
Normal 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
1321
server/platform/services/sharedchannel/sync_send_remote.go
Normal file
1321
server/platform/services/sharedchannel/sync_send_remote.go
Normal file
File diff suppressed because it is too large
Load Diff
508
server/platform/services/sharedchannel/sync_send_test.go
Normal file
508
server/platform/services/sharedchannel/sync_send_test.go
Normal 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)
|
||||
}
|
||||
139
server/platform/services/sharedchannel/util.go
Normal file
139
server/platform/services/sharedchannel/util.go
Normal 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
|
||||
}
|
||||
250
server/platform/services/sharedchannel/util_test.go
Normal file
250
server/platform/services/sharedchannel/util_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user