mirror of
https://github.com/hanzoai/team.git
synced 2026-09-30 01:57:35 +00:00
* 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.
194 lines
6.1 KiB
Go
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)
|
|
}
|