Files
team/server/channels/api4/shared_channel_remotes_test.go
Doug LauderandGitHub f8aa6ed4dc MM-66162: harden GET /sharedchannels/{id}/remotes error path and add WS guard (#36696)
* MM-66162: harden GET /sharedchannels/{id}/remotes error path and add WS guard

  Two small follow-ups on top of the existing fix that returns 200 with an
  empty list when a channel is not shared.

  Server: getSharedChannelRemotes built its 500 AppError with nil params,
  so the i18n template "Could not fetch status for secure connections:
  {{.Error}}." rendered as "<no value>" in logs and response bodies when a
  genuinely unexpected error occurred. Pass map[string]any{"Error":
  err.Error()} so the underlying error surfaces, matching the pattern
  already used by the slash command caller in
  channels/app/slashcommands/command_share.go.

  Webapp: handleSharedChannelRemoteUpdatedEvent dispatched
  fetchChannelRemotes unconditionally on every shared_channel_remote_updated
  WS event, including for channels the local state already knows are not
  shared. The server publishes this event from the onInvite callback after
  a remote accepts a channel invitation, by which point the channel has
  already been flipped to shared and the channel_updated event that did so
  has already fired, so the local channel should reflect shared=true when
  this event arrives. If it does not, the event is not actionable and we
  skip the fetch.

  Adds jest coverage for the four cases of the new guard.

* add regression test for stale shared channel state

  Pins the DB inconsistency scenario suggested in the original ticket
  investigation: Channel.shared is true and a SharedChannelRemote row
  exists, but the SharedChannels row is missing. The endpoint should
  return 200 with an empty list rather than surfacing the stale state
  as an internal server error. Also tightens the existing
  TestGetSharedChannelRemotes by asserting the status code on the
  success path.
2026-05-22 09:59:15 -04:00

194 lines
6.1 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sort"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestGetSharedChannelRemotes(t *testing.T) {
th := setupForSharedChannels(t).InitBasic(t)
// Create remote clusters
remote1 := &model.RemoteCluster{
Name: "remote1",
DisplayName: "Remote Cluster 1",
SiteURL: "http://example.com",
CreatorId: th.BasicUser.Id,
Token: model.NewId(),
LastPingAt: model.GetMillis(),
}
remote1, appErr := th.App.AddRemoteCluster(remote1)
require.Nil(t, appErr)
remote2 := &model.RemoteCluster{
Name: "remote2",
DisplayName: "Remote Cluster 2",
SiteURL: "http://example.org",
CreatorId: th.BasicUser.Id,
Token: model.NewId(),
LastPingAt: model.GetMillis(),
}
remote2, appErr = th.App.AddRemoteCluster(remote2)
require.Nil(t, appErr)
// Create shared channel
channel1 := th.CreateChannelWithClientAndTeam(t, th.Client, model.ChannelTypeOpen, th.BasicTeam.Id)
sc1 := &model.SharedChannel{
ChannelId: channel1.Id,
TeamId: th.BasicTeam.Id,
Home: true,
ReadOnly: false,
ShareName: channel1.Name,
ShareDisplayName: channel1.DisplayName,
SharePurpose: channel1.Purpose,
ShareHeader: channel1.Header,
CreatorId: th.BasicUser.Id,
}
_, sErr := th.App.ShareChannel(th.Context, sc1)
require.NoError(t, sErr)
// Add remotes to channel1
scr1 := &model.SharedChannelRemote{
ChannelId: sc1.ChannelId,
RemoteId: remote1.RemoteId,
CreatorId: th.BasicUser.Id,
IsInviteAccepted: true,
IsInviteConfirmed: true,
}
_, sErr = th.App.SaveSharedChannelRemote(scr1)
require.NoError(t, sErr)
scr2 := &model.SharedChannelRemote{
ChannelId: sc1.ChannelId,
RemoteId: remote2.RemoteId,
CreatorId: th.BasicUser.Id,
IsInviteAccepted: true,
IsInviteConfirmed: true,
}
_, sErr = th.App.SaveSharedChannelRemote(scr2)
require.NoError(t, sErr)
// Test the API endpoint
url := fmt.Sprintf("/sharedchannels/%s/remotes", channel1.Id)
resp, err := th.Client.DoAPIGet(context.Background(), url, "")
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var result []*model.RemoteClusterInfo
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
// Verify response
require.NotNil(t, result)
require.Len(t, result, 2)
// Sort remote infos by display name for consistent testing
sort.Slice(result, func(i, j int) bool {
return result[i].DisplayName < result[j].DisplayName
})
// Verify the RemoteClusterInfo objects contain the expected data
assert.Equal(t, remote1.DisplayName, result[0].DisplayName)
assert.Equal(t, remote2.DisplayName, result[1].DisplayName)
// Should also contain other fields including remote_id for invite/uninvite
assert.NotEmpty(t, result[0].RemoteId)
assert.NotEmpty(t, result[1].RemoteId)
assert.Equal(t, remote1.RemoteId, result[0].RemoteId)
assert.Equal(t, remote2.RemoteId, result[1].RemoteId)
assert.NotEmpty(t, result[0].Name)
assert.NotEmpty(t, result[1].Name)
// Verify the Name matches the actual remote cluster name
assert.Equal(t, remote1.Name, result[0].Name)
assert.Equal(t, remote2.Name, result[1].Name)
assert.NotZero(t, result[0].LastPingAt)
assert.NotZero(t, result[1].LastPingAt)
// Test access control - user without permissions should not be able to access
user2 := th.CreateUser(t)
_, err = th.Client.Logout(context.Background())
require.NoError(t, err)
_, _, err = th.Client.Login(context.Background(), user2.Email, user2.Password)
require.NoError(t, err)
resp, err = th.Client.DoAPIGet(context.Background(), url, "")
require.Error(t, err)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
}
func TestGetSharedChannelRemotes_ReturnsEmptyForNonSharedChannel(t *testing.T) {
th := setupForSharedChannels(t).InitBasic(t)
// Create a channel that is NOT shared
channel := th.CreateChannelWithClientAndTeam(t, th.Client, model.ChannelTypeOpen, th.BasicTeam.Id)
// Request remotes for non-shared channel - should return empty array, not error
url := fmt.Sprintf("/sharedchannels/%s/remotes", channel.Id)
resp, err := th.Client.DoAPIGet(context.Background(), url, "")
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var result []*model.RemoteClusterInfo
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
require.NotNil(t, result)
require.Empty(t, result)
}
func TestGetSharedChannelRemotes_ReturnsEmptyForStaleSharedChannelState(t *testing.T) {
th := setupForSharedChannels(t).InitBasic(t)
channel := th.CreateChannelWithClientAndTeam(t, th.Client, model.ChannelTypeOpen, th.BasicTeam.Id)
require.NoError(t, th.App.Srv().Store().Channel().SetShared(channel.Id, true))
remote, appErr := th.App.AddRemoteCluster(&model.RemoteCluster{
Name: "stale-remote",
DisplayName: "Stale Remote",
SiteURL: "http://stale.example.com",
CreatorId: th.BasicUser.Id,
Token: model.NewId(),
LastPingAt: model.GetMillis(),
})
require.Nil(t, appErr)
// Simulate stale DB state: the channel/remote rows remain, but the SharedChannels row is missing.
_, err := th.App.Srv().Store().SharedChannel().SaveRemote(&model.SharedChannelRemote{
ChannelId: channel.Id,
RemoteId: remote.RemoteId,
CreatorId: th.BasicUser.Id,
IsInviteAccepted: true,
IsInviteConfirmed: true,
})
require.NoError(t, err)
hasRemote, err := th.App.Srv().Store().SharedChannel().HasRemote(channel.Id, remote.RemoteId)
require.NoError(t, err)
require.True(t, hasRemote)
url := fmt.Sprintf("/sharedchannels/%s/remotes", channel.Id)
resp, err := th.Client.DoAPIGet(context.Background(), url, "")
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var result []*model.RemoteClusterInfo
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
require.NotNil(t, result)
require.Empty(t, result)
}