Files
34aeee9e52 [MM-69691] Fix /secure-connection status ordering and malformed table header (#37347)
* [MM-69691] Fix /secure-connection status table sorting and header

Sort deleted connections to the bottom (active first, ordered by
CreateAt within each group) and remove the spurious empty cell from
the Markdown table header separator so it has 9 cells matching the
header and data rows.

Co-authored-by: mattermost-code <[email protected]>

* [MM-69691] Add test for /secure-connection status output

Verifies deleted connections sort to the bottom (active first, ordered
by CreateAt within each group) and that the Markdown table header
separator has exactly nine cells matching the columns.

Co-authored-by: mattermost-code <[email protected]>

* [MM-69691] Strengthen /secure-connection status test assertions

Assert the separator row equals the exact nine-cell string and has
nine alignment cells, verify data rows have nine cells, and cover
CreateAt ordering (overriding alphabetical) plus stable ordering for
equal CreateAt within a group.

Co-authored-by: mattermost-code <[email protected]>

* [MM-69691] Use strings.SplitSeq in status test to satisfy linter

Co-authored-by: mattermost-code <[email protected]>

* [MM-69691] Retrigger CI after buildenv artifact infra failure

Co-authored-by: mattermost-code <[email protected]>

* [MM-69691] Retrigger Enterprise CI after npm ECONNRESET flake

Co-authored-by: mattermost-code <[email protected]>

* Add missing return in doStatus error path

Address review feedback from @wiggin77: the error branch in doStatus
built an error response but discarded it, so execution fell through to
the empty-list check on a nil list. Return the response instead.

---------

Co-authored-by: mattermost-code <[email protected]>

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-04 16:16:11 -04:00

302 lines
11 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"errors"
"fmt"
"sort"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
)
const (
AvailableRemoteActions = "create, accept, remove, status"
)
type RemoteProvider struct {
}
// ensure RemoteProvider implements AutocompleteDynamicArgProvider
var _ app.AutocompleteDynamicArgProvider = (*RemoteProvider)(nil)
const (
CommandTriggerRemote = "secure-connection"
)
func init() {
app.RegisterCommandProvider(&RemoteProvider{})
}
func (rp *RemoteProvider) GetTrigger() string {
return CommandTriggerRemote
}
func (rp *RemoteProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
remote := model.NewAutocompleteData(rp.GetTrigger(), "[action]", T("api.command_remote.remote_add_remove.help", map[string]any{"Actions": AvailableRemoteActions}))
create := model.NewAutocompleteData("create", "", T("api.command_remote.invite.help"))
create.AddNamedTextArgument("name", T("api.command_remote.name.help"), T("api.command_remote.name.hint"), "", true)
create.AddNamedTextArgument("displayname", T("api.command_remote.displayname.help"), T("api.command_remote.displayname.hint"), "", false)
create.AddNamedTextArgument("password", T("api.command_remote.invite_password.help"), T("api.command_remote.invite_password.hint"), "", true)
accept := model.NewAutocompleteData("accept", "", T("api.command_remote.accept.help"))
accept.AddNamedTextArgument("name", T("api.command_remote.name.help"), T("api.command_remote.name.hint"), "", true)
accept.AddNamedTextArgument("displayname", T("api.command_remote.displayname.help"), T("api.command_remote.displayname.hint"), "", false)
accept.AddNamedTextArgument("password", T("api.command_remote.invite_password.help"), T("api.command_remote.invite_password.hint"), "", true)
accept.AddNamedTextArgument("invite", T("api.command_remote.invitation.help"), T("api.command_remote.invitation.hint"), "", true)
remove := model.NewAutocompleteData("remove", "", T("api.command_remote.remove.help"))
remove.AddNamedDynamicListArgument("connectionID", T("api.command_remote.remove_remote_id.help"), "builtin:"+CommandTriggerRemote, true)
status := model.NewAutocompleteData("status", "", T("api.command_remote.status.help"))
remote.AddCommand(create)
remote.AddCommand(accept)
remote.AddCommand(remove)
remote.AddCommand(status)
return &model.Command{
Trigger: rp.GetTrigger(),
AutoComplete: true,
AutoCompleteDesc: T("api.command_remote.desc"),
AutoCompleteHint: T("api.command_remote.hint"),
DisplayName: T("api.command_remote.name"),
AutocompleteData: remote,
}
}
func (rp *RemoteProvider) DoCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse {
if !a.HasPermissionTo(args.UserId, model.PermissionManageSecureConnections) {
return response(args.T("api.command_remote.permission_required", map[string]any{"Permission": "manage_secure_connections"}))
}
margs := parseNamedArgs(args.Command)
action, ok := margs[ActionKey]
if !ok {
return response(args.T("api.command_remote.missing_command", map[string]any{"Actions": AvailableRemoteActions}))
}
switch action {
case "create":
return rp.doCreate(a, args, margs)
case "accept":
return rp.doAccept(a, args, margs)
case "remove":
return rp.doRemove(a, args, margs)
case "status":
return rp.doStatus(a, args, margs)
}
return response(args.T("api.command_remote.unknown_action", map[string]any{"Action": action}))
}
func (rp *RemoteProvider) GetAutoCompleteListItems(rctx request.CTX, a *app.App, commandArgs *model.CommandArgs, arg *model.AutocompleteArg, parsed, toBeParsed string) ([]model.AutocompleteListItem, error) {
if !a.HasPermissionTo(commandArgs.UserId, model.PermissionManageSecureConnections) {
return nil, errors.New("You require `manage_secure_connections` permission to manage secure connections.")
}
if arg.Name == "connectionID" && strings.Contains(parsed, " remove ") {
return getRemoteClusterAutocompleteListItems(a, true)
}
return nil, fmt.Errorf("`%s` is not a dynamic argument", arg.Name)
}
// doCreate creates and displays an encrypted invite that can be used by a remote site to establish a simple trust.
func (rp *RemoteProvider) doCreate(a *app.App, args *model.CommandArgs, margs map[string]string) *model.CommandResponse {
password := margs["password"]
if password == "" {
return response(args.T("api.command_remote.missing_empty", map[string]any{"Arg": "password"}))
}
name := margs["name"]
if name == "" {
return response(args.T("api.command_remote.missing_empty", map[string]any{"Arg": "name"}))
}
displayname := margs["displayname"]
if displayname == "" {
displayname = name
}
url := a.GetSiteURL()
if url == "" {
return response(args.T("api.command_remote.site_url_not_set"))
}
rc := &model.RemoteCluster{
Name: name,
DisplayName: displayname,
SiteURL: model.SiteURLPending + model.NewId(), // require a unique siteurl
Token: model.NewId(),
CreatorId: args.UserId,
}
rcSaved, appErr := a.AddRemoteCluster(rc)
if appErr != nil {
return response(args.T("api.command_remote.add_remote.error", map[string]any{"Error": appErr.Error()}))
}
// Display the encrypted invitation
inviteCode, err := a.CreateRemoteClusterInvite(rcSaved.RemoteId, url, rcSaved.Token, password)
if err != nil {
return response(args.T("api.command_remote.encrypt_invitation.error", map[string]any{"Error": err.Error()}))
}
return response("##### " + args.T("api.command_remote.invitation_created") + "\n" +
args.T("api.command_remote.invite_summary", map[string]any{"Command": "/secure-connection accept", "Invitation": inviteCode, "SiteURL": url}))
}
// doAccept accepts an invitation generated by a remote site.
func (rp *RemoteProvider) doAccept(a *app.App, args *model.CommandArgs, margs map[string]string) *model.CommandResponse {
password := margs["password"]
if password == "" {
return response(args.T("api.command_remote.missing_empty", map[string]any{"Arg": "password"}))
}
name := margs["name"]
if name == "" {
return response(args.T("api.command_remote.missing_empty", map[string]any{"Arg": "name"}))
}
displayname := margs["displayname"]
if displayname == "" {
displayname = name
}
blob := margs["invite"]
if blob == "" {
return response(args.T("api.command_remote.missing_empty", map[string]any{"Arg": "invite"}))
}
// invite is encoded as base64 and encrypted
invite, dErr := a.DecryptRemoteClusterInvite(blob, password)
if dErr != nil {
return response(args.T("api.command_remote.decode_invitation.error", map[string]any{"Error": dErr.Error()}))
}
rcs, _ := a.GetRemoteClusterService()
if rcs == nil {
return response(args.T("api.command_remote.service_not_enabled"))
}
url := a.GetSiteURL()
if url == "" {
return response(args.T("api.command_remote.site_url_not_set"))
}
rc, err := rcs.AcceptInvitation(invite, name, displayname, args.UserId, url, "")
if err != nil {
return response(args.T("api.command_remote.accept_invitation.error", map[string]any{"Error": err.Error()}))
}
return response("##### " + args.T("api.command_remote.accept_invitation", map[string]any{"SiteURL": rc.GetSiteURL()}))
}
// doRemove removes a remote cluster from the database, effectively revoking the trust relationship.
func (rp *RemoteProvider) doRemove(a *app.App, args *model.CommandArgs, margs map[string]string) *model.CommandResponse {
id, ok := margs["connectionID"]
if !ok {
return response(args.T("api.command_remote.missing_empty", map[string]any{"Arg": "remoteId"}))
}
deleted, err := a.DeleteRemoteCluster(id)
if err != nil {
response(args.T("api.command_remote.remove_remote.error", map[string]any{"Error": err.Error()}))
}
result := "removed"
if !deleted {
result = "**NOT FOUND**"
}
return response("##### " + args.T("api.command_remote.cluster_removed", map[string]any{"RemoteId": id, "Result": result}))
}
// doStatus displays connection status for all remote clusters.
func (rp *RemoteProvider) doStatus(a *app.App, args *model.CommandArgs, _ map[string]string) *model.CommandResponse {
list, err := a.GetAllRemoteClusters(0, 999999, model.RemoteClusterQueryFilter{IncludeDeleted: true})
if err != nil {
return response(args.T("api.command_remote.fetch_status.error", map[string]any{"Error": err.Error()}))
}
if len(list) == 0 {
return response("** " + args.T("api.command_remote.remotes_not_found") + " **")
}
// Show active connections first, then deleted ones, ordered by creation time within each group.
sort.SliceStable(list, func(i, j int) bool {
iDeleted := list[i].DeleteAt != 0
jDeleted := list[j].DeleteAt != 0
if iDeleted != jDeleted {
return !iDeleted
}
return list[i].CreateAt < list[j].CreateAt
})
var sb strings.Builder
fmt.Fprintf(&sb, "%s \n", args.T("api.command_remote.remote_table_header"))
// | Secure Connection | Display name | ConnectionID | Site URL | Default Team | Invite accepted | Online | Last ping | Deleted |
fmt.Fprintf(&sb, "| :---- | :---- | :---- | :---- | :---- | :---- | :---- | :---- | :---- |\n")
for _, rc := range list {
accepted := formatBool(args.T, rc.IsConfirmed())
online := formatBool(args.T, isOnline(rc.LastPingAt))
lastPing := formatTimestamp(rc.LastPingAt)
deleted := formatBool(args.T, rc.DeleteAt != 0)
fmt.Fprintf(&sb, "| %s | %s | %s | %s | %s | %s | %s | %s | %s |\n", rc.Name, rc.DisplayName, rc.RemoteId, rc.GetSiteURL(), rc.DefaultTeamId, accepted, online, lastPing, deleted)
}
return response(sb.String())
}
func isOnline(lastPing int64) bool {
return lastPing > model.GetMillis()-model.RemoteOfflineAfterMillis
}
func getRemoteClusterAutocompleteListItems(a *app.App, includeOffline bool) ([]model.AutocompleteListItem, error) {
filter := model.RemoteClusterQueryFilter{
ExcludeOffline: !includeOffline,
}
clusters, err := a.GetAllRemoteClusters(0, 999999, filter)
if err != nil || len(clusters) == 0 {
return []model.AutocompleteListItem{}, nil
}
list := make([]model.AutocompleteListItem, 0, len(clusters))
for _, rc := range clusters {
item := model.AutocompleteListItem{
Item: rc.RemoteId,
HelpText: fmt.Sprintf("%s (%s)", rc.DisplayName, rc.GetSiteURL())}
list = append(list, item)
}
return list, nil
}
func getRemoteClusterAutocompleteListItemsNotInChannel(a *app.App, channelID string, includeOffline bool) ([]model.AutocompleteListItem, error) {
filter := model.RemoteClusterQueryFilter{
ExcludeOffline: !includeOffline,
NotInChannel: channelID,
}
all, err := a.GetAllRemoteClusters(0, 999999, filter)
if err != nil || len(all) == 0 {
return []model.AutocompleteListItem{}, nil
}
list := make([]model.AutocompleteListItem, 0, len(all))
for _, rc := range all {
item := model.AutocompleteListItem{
Item: rc.RemoteId,
HelpText: fmt.Sprintf("%s (%s)", rc.DisplayName, rc.GetSiteURL())}
list = append(list, item)
}
return list, nil
}