From da5d7c8c6e90a0e85b6e2d89e55e38ba2ff57680 Mon Sep 17 00:00:00 2001 From: "cursor[bot]" <206951365+cursor[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:06:29 -0400 Subject: [PATCH 1/3] Add Classification Markings discovery page for licenses below Enterprise (#37060) * Add Classification Markings discovery page for non-Enterprise licenses Surface a feature discovery/upsell page in the System Console when the ClassificationMarkings feature flag is enabled but the license tier is below Enterprise (e.g. Professional). Previously the page was hidden entirely, so the feature was never surfaced to admins. Co-authored-by: mattermost-code * Add tests for Classification Markings discovery gating Co-authored-by: mattermost-code * Add render test and expand gating coverage for Classification Markings discovery Co-authored-by: mattermost-code * Assert feature flag gate overrides license for Classification Markings Co-authored-by: mattermost-code * Run i18n-extract for Classification Markings discovery strings Co-authored-by: mattermost-code * Fix broken learn more URL for Classification Markings discovery page The classification markings documentation page does not exist yet, causing check-external-links CI to fail. Use the CMMC compliance guide which covers system-wide and channel-specific classification banners. Co-authored-by: mattermost-code * Use nullish coalescing for SVG dimension defaults Address CodeRabbit feedback: honor explicit 0 width/height values instead of treating them as missing. Co-authored-by: mattermost-code * Address PR feedback: 0 answered, 1 resolved, 0 declined --------- Co-authored-by: Cursor Agent Co-authored-by: mattermost-code --- .../admin_console/admin_definition.tsx | 23 ++ ...efinition_classification_markings.test.tsx | 151 ++++++++ .../features/classification_markings.test.tsx | 57 +++ .../features/classification_markings.tsx | 37 ++ .../images/classification_markings_svg.tsx | 347 ++++++++++++++++++ .../feature_discovery/features/index.ts | 2 + webapp/channels/src/i18n/en.json | 2 + 7 files changed, 619 insertions(+) create mode 100644 webapp/channels/src/components/admin_console/admin_definition_classification_markings.test.tsx create mode 100644 webapp/channels/src/components/admin_console/feature_discovery/features/classification_markings.test.tsx create mode 100644 webapp/channels/src/components/admin_console/feature_discovery/features/classification_markings.tsx create mode 100644 webapp/channels/src/components/admin_console/feature_discovery/features/images/classification_markings_svg.tsx diff --git a/webapp/channels/src/components/admin_console/admin_definition.tsx b/webapp/channels/src/components/admin_console/admin_definition.tsx index 9675831d5e04..6f9984c754a6 100644 --- a/webapp/channels/src/components/admin_console/admin_definition.tsx +++ b/webapp/channels/src/components/admin_console/admin_definition.tsx @@ -73,6 +73,7 @@ import DatabaseSettings, {searchableStrings as databaseSearchableStrings} from ' import ElasticSearchSettings, {searchableStrings as elasticSearchSearchableStrings} from './elasticsearch_settings'; import { AnnouncementBannerFeatureDiscovery, + ClassificationMarkingsFeatureDiscovery, ComplianceExportFeatureDiscovery, CustomTermsOfServiceFeatureDiscovery, DataSpillageFeatureDiscovery, @@ -3462,6 +3463,28 @@ const AdminDefinition: AdminDefinitionType = { component: ClassificationMarkings, }, }, + classification_markings_feature_discovery: { + url: 'site_config/classification_markings', + isDiscovery: true, + title: defineMessage({id: 'admin.sidebar.classificationMarkings', defaultMessage: 'Classification Markings'}), + isHidden: it.any( + it.minLicenseTier(LicenseSkus.Enterprise), + it.not(it.configIsTrue('FeatureFlags', 'ClassificationMarkings')), + ), + schema: { + id: 'ClassificationMarkings', + name: defineMessage({id: 'admin.sidebar.classificationMarkings', defaultMessage: 'Classification Markings'}), + settings: [ + { + type: 'custom', + component: ClassificationMarkingsFeatureDiscovery, + key: 'ClassificationMarkingsFeatureDiscovery', + isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.ABOUT.EDITION_AND_LICENSE)), + }, + ], + }, + restrictedIndicator: getRestrictedIndicator(true, LicenseSkus.EnterpriseAdvanced), + }, announcement_banner: { url: 'site_config/announcement_banner', title: defineMessage({id: 'admin.sidebar.announcement', defaultMessage: 'System-wide Notifications'}), diff --git a/webapp/channels/src/components/admin_console/admin_definition_classification_markings.test.tsx b/webapp/channels/src/components/admin_console/admin_definition_classification_markings.test.tsx new file mode 100644 index 000000000000..5cba03ded75a --- /dev/null +++ b/webapp/channels/src/components/admin_console/admin_definition_classification_markings.test.tsx @@ -0,0 +1,151 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {AdminConfig, ClientLicense} from '@mattermost/types/config'; + +import {RESOURCE_KEYS} from 'mattermost-redux/constants/permissions_sysconsole'; + +import {LicenseSkus} from 'utils/constants'; + +import AdminDefinition from './admin_definition'; +import ClassificationMarkingsFeatureDiscovery from './feature_discovery/features/classification_markings'; +import type {AdminDefinitionSetting, AdminDefinitionSubSection, Check, ConsoleAccess} from './types'; + +const classificationConfigEnabled = { + FeatureFlags: { + ClassificationMarkings: true, + }, +} as unknown as Partial; + +const classificationConfigDisabled = { + FeatureFlags: { + ClassificationMarkings: false, + }, +} as unknown as Partial; + +const consoleAccess = { + read: {}, + write: { + [RESOURCE_KEYS.ABOUT.EDITION_AND_LICENSE]: true, + }, +} as ConsoleAccess; + +const consoleAccessWithoutLicenseWrite = { + ...consoleAccess, + write: { + ...consoleAccess.write, + [RESOURCE_KEYS.ABOUT.EDITION_AND_LICENSE]: false, + }, +} as ConsoleAccess; + +const professionalLicense = { + IsLicensed: 'true', + SkuShortName: LicenseSkus.Professional, +} as ClientLicense; + +const enterpriseLicense = { + IsLicensed: 'true', + SkuShortName: LicenseSkus.Enterprise, +} as ClientLicense; + +const enterpriseAdvancedLicense = { + IsLicensed: 'true', + SkuShortName: LicenseSkus.EnterpriseAdvanced, +} as ClientLicense; + +const entryLicense = { + IsLicensed: 'true', + SkuShortName: LicenseSkus.Entry, +} as ClientLicense; + +const unlicensed = { + IsLicensed: 'false', +} as ClientLicense; + +type CustomAdminDefinitionSetting = Extract; + +function isHidden(subsection: AdminDefinitionSubSection, config: Partial, license: ClientLicense) { + const check = subsection.isHidden as Extract boolean>; + return check(config, {}, license, true, consoleAccess); +} + +function isDisabled(check: Check | undefined, access: ConsoleAccess) { + const disabledCheck = check as Extract boolean>; + return disabledCheck(classificationConfigEnabled, {}, professionalLicense, true, access); +} + +describe('AdminDefinition - Classification Markings discovery', () => { + const settingsSubsection = AdminDefinition.site.subsections.classification_markings; + const discoverySubsection = AdminDefinition.site.subsections.classification_markings_feature_discovery; + + test('includes a discovery route at the Classification Markings URL', () => { + expect(discoverySubsection).toBeDefined(); + expect(discoverySubsection.url).toBe(settingsSubsection.url); + expect(discoverySubsection.isDiscovery).toBe(true); + expect(discoverySubsection.title).toEqual(settingsSubsection.title); + expect(discoverySubsection.restrictedIndicator).toBeDefined(); + + const schema = discoverySubsection.schema; + expect('name' in schema ? schema.name : undefined).toEqual(settingsSubsection.title); + }); + + test('shows discovery instead of settings for Professional licenses', () => { + expect(isHidden(settingsSubsection, classificationConfigEnabled, professionalLicense)).toBe(true); + expect(isHidden(discoverySubsection, classificationConfigEnabled, professionalLicense)).toBe(false); + }); + + test('shows discovery instead of settings when unlicensed', () => { + expect(isHidden(settingsSubsection, classificationConfigEnabled, unlicensed)).toBe(true); + expect(isHidden(discoverySubsection, classificationConfigEnabled, unlicensed)).toBe(false); + }); + + test('shows settings instead of discovery for Enterprise licenses', () => { + expect(isHidden(settingsSubsection, classificationConfigEnabled, enterpriseLicense)).toBe(false); + expect(isHidden(discoverySubsection, classificationConfigEnabled, enterpriseLicense)).toBe(true); + }); + + test('shows settings instead of discovery for Enterprise Advanced licenses', () => { + expect(isHidden(settingsSubsection, classificationConfigEnabled, enterpriseAdvancedLicense)).toBe(false); + expect(isHidden(discoverySubsection, classificationConfigEnabled, enterpriseAdvancedLicense)).toBe(true); + }); + + test('shows settings instead of discovery for Entry licenses', () => { + expect(isHidden(settingsSubsection, classificationConfigEnabled, entryLicense)).toBe(false); + expect(isHidden(discoverySubsection, classificationConfigEnabled, entryLicense)).toBe(true); + }); + + test('disables the settings page for non system admins', () => { + const settingsDisabledCheck = settingsSubsection.isDisabled as Extract boolean>; + + const asSystemAdmin = settingsDisabledCheck(classificationConfigEnabled, {}, enterpriseAdvancedLicense, true, consoleAccess, undefined, true); + const asNonSystemAdmin = settingsDisabledCheck(classificationConfigEnabled, {}, enterpriseAdvancedLicense, true, consoleAccess, undefined, false); + + expect(asSystemAdmin).toBe(false); + expect(asNonSystemAdmin).toBe(true); + }); + + test('hides both settings and discovery when the Classification Markings feature flag is disabled', () => { + expect(isHidden(settingsSubsection, classificationConfigDisabled, professionalLicense)).toBe(true); + expect(isHidden(discoverySubsection, classificationConfigDisabled, professionalLicense)).toBe(true); + + // The disabled flag must override an otherwise-unlocking license. + expect(isHidden(settingsSubsection, classificationConfigDisabled, enterpriseLicense)).toBe(true); + expect(isHidden(discoverySubsection, classificationConfigDisabled, enterpriseLicense)).toBe(true); + }); + + test('renders the Classification Markings feature discovery component through a custom setting', () => { + const schema = discoverySubsection.schema; + expect('settings' in schema).toBe(true); + + const settings = 'settings' in schema ? schema.settings ?? [] : []; + const discoverySetting = settings.find((setting): setting is CustomAdminDefinitionSetting => ( + setting.type === 'custom' && setting.key === 'ClassificationMarkingsFeatureDiscovery' + )); + + expect(discoverySetting).toBeDefined(); + expect(discoverySetting?.type).toBe('custom'); + expect(discoverySetting?.component).toBe(ClassificationMarkingsFeatureDiscovery); + expect(isDisabled(discoverySetting?.isDisabled, consoleAccess)).toBe(false); + expect(isDisabled(discoverySetting?.isDisabled, consoleAccessWithoutLicenseWrite)).toBe(true); + }); +}); diff --git a/webapp/channels/src/components/admin_console/feature_discovery/features/classification_markings.test.tsx b/webapp/channels/src/components/admin_console/feature_discovery/features/classification_markings.test.tsx new file mode 100644 index 000000000000..e86a6d1cb94b --- /dev/null +++ b/webapp/channels/src/components/admin_console/feature_discovery/features/classification_markings.test.tsx @@ -0,0 +1,57 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {renderWithContext, screen} from 'tests/react_testing_utils'; + +import ClassificationMarkingsFeatureDiscovery from './classification_markings'; + +jest.mock('../index', () => { + const React = require('react'); + const FeatureDiscovery = require('../feature_discovery').default; + + return { + __esModule: true, + default: (props: Record) => ( + + ), + }; +}); + +describe('components/admin_console/feature_discovery/features/ClassificationMarkingsFeatureDiscovery', () => { + it('renders the Classification Markings discovery card', () => { + renderWithContext(); + + expect(screen.getByText('Apply classification markings with Mattermost Enterprise Advanced')).toBeInTheDocument(); + expect(screen.getByText( + 'Set up global and channel-specific classification banners with built-in presets or custom levels, ensuring that users consistently view the appropriate classification level for their workspace.', + )).toBeInTheDocument(); + + expect(screen.getByRole('button', {name: 'Contact sales'})).toBeInTheDocument(); + expect(screen.getByRole('link', {name: 'Learn more'})).toHaveAttribute( + 'href', + expect.stringContaining('https://docs.mattermost.com/end-user-guide/collaborate/display-channel-banners.html'), + ); + expect(screen.getByRole('link', {name: 'Learn more'})).toHaveAttribute( + 'href', + expect.stringContaining('#classification-markings'), + ); + expect(document.querySelector('.FeatureDiscovery_imageWrapper svg')).toBeInTheDocument(); + }); +}); diff --git a/webapp/channels/src/components/admin_console/feature_discovery/features/classification_markings.tsx b/webapp/channels/src/components/admin_console/feature_discovery/features/classification_markings.tsx new file mode 100644 index 000000000000..6ec3c29deddc --- /dev/null +++ b/webapp/channels/src/components/admin_console/feature_discovery/features/classification_markings.tsx @@ -0,0 +1,37 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {defineMessage} from 'react-intl'; + +import {LicenseSkus} from 'utils/constants'; + +import ClassificationMarkingsSVG from './images/classification_markings_svg'; + +import FeatureDiscovery from '../index'; + +const ClassificationMarkingsFeatureDiscovery: React.FC = () => { + return ( + + } + /> + ); +}; + +export default ClassificationMarkingsFeatureDiscovery; diff --git a/webapp/channels/src/components/admin_console/feature_discovery/features/images/classification_markings_svg.tsx b/webapp/channels/src/components/admin_console/feature_discovery/features/images/classification_markings_svg.tsx new file mode 100644 index 000000000000..95389fdc56ea --- /dev/null +++ b/webapp/channels/src/components/admin_console/feature_discovery/features/images/classification_markings_svg.tsx @@ -0,0 +1,347 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +type SvgProps = { + width?: number; + height?: number; +}; + +const ClassificationMarkingsSVG = (props: SvgProps) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +export default ClassificationMarkingsSVG; diff --git a/webapp/channels/src/components/admin_console/feature_discovery/features/index.ts b/webapp/channels/src/components/admin_console/feature_discovery/features/index.ts index 68fefacc4d34..bb8468176931 100644 --- a/webapp/channels/src/components/admin_console/feature_discovery/features/index.ts +++ b/webapp/channels/src/components/admin_console/feature_discovery/features/index.ts @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import AnnouncementBannerFeatureDiscovery from './announcement_banner'; +import ClassificationMarkingsFeatureDiscovery from './classification_markings'; import ComplianceExportFeatureDiscovery from './compliance_export'; import CustomTermsOfServiceFeatureDiscovery from './custom_terms_of_service'; import DataRetentionFeatureDiscovery from './data_retention'; @@ -23,6 +24,7 @@ export { OpenIDCustomFeatureDiscovery, GitLabFeatureDiscovery, AnnouncementBannerFeatureDiscovery, + ClassificationMarkingsFeatureDiscovery, ComplianceExportFeatureDiscovery, CustomTermsOfServiceFeatureDiscovery, DataSpillageFeatureDiscovery, diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 723ab2eb575b..992305effae9 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -749,6 +749,8 @@ "admin.channelSettings.channelDetail.channel_organizations": "Organizations", "admin.channelSettings.channelDetail.channelName": "Name", "admin.channelSettings.channelDetail.channelTeam": "Team", + "admin.classification_markings_feature_discovery.desc": "Set up global and channel-specific classification banners with built-in presets or custom levels, ensuring that users consistently view the appropriate classification level for their workspace.", + "admin.classification_markings_feature_discovery.title": "Apply classification markings with Mattermost Enterprise Advanced", "admin.classification_markings.color.open_picker": "Open color picker", "admin.classification_markings.enable.description": "Use this to enable classification markings as banners at the system and channel level. You can pre-select text and colors for your banner, as well as set a default option for consistency.", "admin.classification_markings.enable.false": "False", From 29025d478dc0030df9bc9ff65dbf60a772ec05eb Mon Sep 17 00:00:00 2001 From: Christopher Poile Date: Wed, 17 Jun 2026 17:21:46 -0400 Subject: [PATCH 2/3] [MM-69343] Add MessagesWillBeConsumedWithContext hook (#37091) --- server/channels/app/plugin_hooks_test.go | 87 +++++++++++++++++++ server/channels/app/post.go | 61 ++++++++----- server/public/plugin/client_rpc.go | 38 ++++++++ server/public/plugin/hooks.go | 13 +++ .../plugin/hooks_timer_layer_generated.go | 7 ++ .../public/plugin/interface_generator/main.go | 1 + server/public/plugin/plugintest/hooks.go | 20 +++++ 7 files changed, 205 insertions(+), 22 deletions(-) diff --git a/server/channels/app/plugin_hooks_test.go b/server/channels/app/plugin_hooks_test.go index bcd34e0b090b..a0c4a30a43f8 100644 --- a/server/channels/app/plugin_hooks_test.go +++ b/server/channels/app/plugin_hooks_test.go @@ -1929,6 +1929,93 @@ func TestHookMessagesWillBeConsumed(t *testing.T) { }) } +func TestHookMessagesWillBeConsumedWithContext(t *testing.T) { + mainHelper.Parallel(t) + + setupPlugin := func(t *testing.T, th *TestHelper) { + var mockAPI plugintest.API + mockAPI.On("LoadPluginConfiguration", mock.Anything).Return(nil) + mockAPI.On("LogDebug", "message").Return(nil) + + // The plugin records whether it received a non-nil context to confirm the context is + // threaded all the way through to the hook. + tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{` + package main + + import ( + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" + ) + + type MyPlugin struct { + plugin.MattermostPlugin + } + + func (p *MyPlugin) MessagesWillBeConsumedWithContext(c *plugin.Context, posts []*model.Post) []*model.Post { + prefix := "mwbcwc_plugin:" + if c == nil { + prefix = "nilctx:" + } + for _, post := range posts { + post.Message = prefix + post.Message + } + return posts + } + + func main() { + plugin.ClientMain(&MyPlugin{}) + } + `}, th.App, func(*model.Manifest) plugin.API { return &mockAPI }) + t.Cleanup(tearDown) + } + + t.Run("feature flag disabled", func(t *testing.T) { + mainHelper.Parallel(t) + + th := SetupConfig(t, func(cfg *model.Config) { + cfg.FeatureFlags.ConsumePostHook = false + }).InitBasic(t) + + setupPlugin(t, th) + + newPost := &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "message", + CreateAt: model.GetMillis() - 10000, + } + _, _, err := th.App.CreatePost(th.Context, newPost, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + require.Nil(t, err) + + post, err := th.App.GetSinglePost(th.Context, newPost.Id, true) + require.Nil(t, err) + assert.Equal(t, "message", post.Message) + }) + + t.Run("feature flag enabled", func(t *testing.T) { + mainHelper.Parallel(t) + + th := SetupConfig(t, func(cfg *model.Config) { + cfg.FeatureFlags.ConsumePostHook = true + }).InitBasic(t) + + setupPlugin(t, th) + + newPost := &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "message", + CreateAt: model.GetMillis() - 10000, + } + _, _, err := th.App.CreatePost(th.Context, newPost, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + require.Nil(t, err) + + post, err := th.App.GetSinglePost(th.Context, newPost.Id, true) + require.Nil(t, err) + assert.Equal(t, "mwbcwc_plugin:message", post.Message) + }) +} + func TestUpdatePostFiresConsumeHook(t *testing.T) { mainHelper.Parallel(t) diff --git a/server/channels/app/post.go b/server/channels/app/post.go index cf18fb00957b..f5dbcec51b9e 100644 --- a/server/channels/app/post.go +++ b/server/channels/app/post.go @@ -8,16 +8,15 @@ import ( "encoding/json" "errors" "fmt" + "maps" "net/http" "regexp" + "slices" "strconv" "strings" "sync" "time" - "maps" - "slices" - "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/plugin" "github.com/mattermost/mattermost/server/public/shared/i18n" @@ -437,7 +436,7 @@ func (a *App) CreatePost(rctx request.CTX, post *model.Post, channel *model.Chan } } - a.applyPostWillBeConsumedHook(&rpost) + a.applyPostWillBeConsumedHook(rctx, &rpost) if rpost.RootId != "" { if appErr := a.ResolvePersistentNotification(rctx, parentPostList.Posts[post.RootId], rpost.UserId); appErr != nil { @@ -982,7 +981,7 @@ func (a *App) UpdatePost(rctx request.CTX, receivedUpdatedPost *model.Post, upda } } - a.applyPostWillBeConsumedHook(&rpost) + a.applyPostWillBeConsumedHook(rctx, &rpost) message := model.NewWebSocketEvent(model.WebsocketEventPostEdited, "", rpost.ChannelId, "", nil, "") @@ -1271,7 +1270,7 @@ func (a *App) GetPostsPage(rctx request.CTX, options model.GetPostsOptions) (*mo return nil, appErr } - a.applyPostsWillBeConsumedHook(postList.Posts) + a.applyPostsWillBeConsumedHook(rctx, postList.Posts) return postList, nil } @@ -1300,7 +1299,7 @@ func (a *App) GetPostsForView(rctx request.CTX, options model.GetPostsOptions) ( return nil, appErr } - a.applyPostsWillBeConsumedHook(postList.Posts) + a.applyPostsWillBeConsumedHook(rctx, postList.Posts) return postList, nil } @@ -1327,7 +1326,7 @@ func (a *App) GetPosts(rctx request.CTX, channelID string, offset int, limit int return nil, appErr } - a.applyPostsWillBeConsumedHook(postList.Posts) + a.applyPostsWillBeConsumedHook(rctx, postList.Posts) return postList, nil } @@ -1364,7 +1363,7 @@ func (a *App) GetPostsSince(rctx request.CTX, options model.GetPostsSinceOptions return nil, appErr } - a.applyPostsWillBeConsumedHook(postList.Posts) + a.applyPostsWillBeConsumedHook(rctx, postList.Posts) return postList, nil } @@ -1461,7 +1460,7 @@ func (a *App) GetSinglePost(rctx request.CTX, postID string, includeDeleted bool return nil, model.NewAppError("GetSinglePost", "app.post.cloud.get.app_error", nil, "", http.StatusForbidden) } - a.applyPostWillBeConsumedHook(&post) + a.applyPostWillBeConsumedHook(rctx, &post) return post, nil } @@ -1499,7 +1498,7 @@ func (a *App) GetPostThread(rctx request.CTX, postID string, opts model.GetPosts return nil, appErr } - a.applyPostsWillBeConsumedHook(posts.Posts) + a.applyPostsWillBeConsumedHook(rctx, posts.Posts) return posts, nil } @@ -1521,7 +1520,7 @@ func (a *App) GetFlaggedPosts(rctx request.CTX, userID string, offset int, limit return nil, appErr } - a.applyPostsWillBeConsumedHook(postList.Posts) + a.applyPostsWillBeConsumedHook(rctx, postList.Posts) return postList, nil } @@ -1543,7 +1542,7 @@ func (a *App) GetFlaggedPostsForTeam(rctx request.CTX, userID, teamID string, of return nil, appErr } - a.applyPostsWillBeConsumedHook(postList.Posts) + a.applyPostsWillBeConsumedHook(rctx, postList.Posts) return postList, nil } @@ -1565,7 +1564,7 @@ func (a *App) GetFlaggedPostsForChannel(rctx request.CTX, userID, channelID stri return nil, appErr } - a.applyPostsWillBeConsumedHook(postList.Posts) + a.applyPostsWillBeConsumedHook(rctx, postList.Posts) return postList, nil } @@ -1609,7 +1608,7 @@ func (a *App) GetPermalinkPost(rctx request.CTX, postID string, userID string) ( return nil, appErr } - a.applyPostsWillBeConsumedHook(list.Posts) + a.applyPostsWillBeConsumedHook(rctx, list.Posts) return list, nil } @@ -1645,7 +1644,7 @@ func (a *App) GetPostsBeforePost(rctx request.CTX, options model.GetPostsOptions return nil, appErr } - a.applyPostsWillBeConsumedHook(postList.Posts) + a.applyPostsWillBeConsumedHook(rctx, postList.Posts) return postList, nil } @@ -1681,7 +1680,7 @@ func (a *App) GetPostsAfterPost(rctx request.CTX, options model.GetPostsOptions) return nil, appErr } - a.applyPostsWillBeConsumedHook(postList.Posts) + a.applyPostsWillBeConsumedHook(rctx, postList.Posts) return postList, nil } @@ -1725,18 +1724,18 @@ func (a *App) GetPostsAroundPost(rctx request.CTX, before bool, options model.Ge return nil, appErr } - a.applyPostsWillBeConsumedHook(postList.Posts) + a.applyPostsWillBeConsumedHook(rctx, postList.Posts) return postList, nil } -func (a *App) GetPostAfterTime(channelID string, time int64, collapsedThreads bool) (*model.Post, *model.AppError) { +func (a *App) GetPostAfterTime(rctx request.CTX, channelID string, time int64, collapsedThreads bool) (*model.Post, *model.AppError) { post, err := a.Srv().Store().Post().GetPostAfterTime(channelID, time, collapsedThreads) if err != nil { return nil, model.NewAppError("GetPostAfterTime", "app.post.get_post_after_time.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } - a.applyPostWillBeConsumedHook(&post) + a.applyPostWillBeConsumedHook(rctx, &post) return post, nil } @@ -2904,7 +2903,7 @@ func (a *App) GetPostInfo(rctx request.CTX, postID string, channel *model.Channe return &info, nil } -func (a *App) applyPostsWillBeConsumedHook(posts map[string]*model.Post) { +func (a *App) applyPostsWillBeConsumedHook(rctx request.CTX, posts map[string]*model.Post) { if !a.Config().FeatureFlags.ConsumePostHook { return } @@ -2921,9 +2920,18 @@ func (a *App) applyPostsWillBeConsumedHook(posts map[string]*model.Post) { } return true }, plugin.MessagesWillBeConsumedID) + + pluginContext := pluginContext(rctx) + a.ch.RunMultiHook(func(hooks plugin.Hooks, _ *model.Manifest) bool { + postReplacements := hooks.MessagesWillBeConsumedWithContext(pluginContext, postsSlice) + for _, postReplacement := range postReplacements { + posts[postReplacement.Id] = postReplacement + } + return true + }, plugin.MessagesWillBeConsumedWithContextID) } -func (a *App) applyPostWillBeConsumedHook(post **model.Post) { +func (a *App) applyPostWillBeConsumedHook(rctx request.CTX, post **model.Post) { if !a.Config().FeatureFlags.ConsumePostHook || (*post).Type == model.PostTypeBurnOnRead { return } @@ -2936,6 +2944,15 @@ func (a *App) applyPostWillBeConsumedHook(post **model.Post) { } return true }, plugin.MessagesWillBeConsumedID) + + pluginContext := pluginContext(rctx) + a.ch.RunMultiHook(func(hooks plugin.Hooks, _ *model.Manifest) bool { + rp := hooks.MessagesWillBeConsumedWithContext(pluginContext, ps) + if len(rp) > 0 { + (*post) = rp[0] + } + return true + }, plugin.MessagesWillBeConsumedWithContextID) } func makePostLink(siteURL, teamName, postID string) string { diff --git a/server/public/plugin/client_rpc.go b/server/public/plugin/client_rpc.go index 76ce6c8825df..8d4519c667cc 100644 --- a/server/public/plugin/client_rpc.go +++ b/server/public/plugin/client_rpc.go @@ -993,6 +993,44 @@ func (s *hooksRPCServer) MessagesWillBeConsumed(args *Z_MessagesWillBeConsumedAr return nil } +// MessagesWillBeConsumedWithContext is in this file because of the difficulty of identifying which fields +// need special behaviour. The special behaviour needed is decoding the returned post into the original one +// to avoid the unintentional removal of fields by older plugins. +func init() { + hookNameToId["MessagesWillBeConsumedWithContext"] = MessagesWillBeConsumedWithContextID +} + +type Z_MessagesWillBeConsumedWithContextArgs struct { + A *Context + B []*model.Post +} + +type Z_MessagesWillBeConsumedWithContextReturns struct { + A []*model.Post +} + +func (g *hooksRPCClient) MessagesWillBeConsumedWithContext(c *Context, posts []*model.Post) []*model.Post { + _args := &Z_MessagesWillBeConsumedWithContextArgs{c, posts} + _returns := &Z_MessagesWillBeConsumedWithContextReturns{} + if g.implemented[MessagesWillBeConsumedWithContextID] { + if err := g.client.Call("Plugin.MessagesWillBeConsumedWithContext", _args, _returns); err != nil { + g.log.Error("RPC call MessagesWillBeConsumedWithContext to plugin failed.", mlog.Err(err)) + } + } + return _returns.A +} + +func (s *hooksRPCServer) MessagesWillBeConsumedWithContext(args *Z_MessagesWillBeConsumedWithContextArgs, returns *Z_MessagesWillBeConsumedWithContextReturns) error { + if hook, ok := s.impl.(interface { + MessagesWillBeConsumedWithContext(c *Context, posts []*model.Post) []*model.Post + }); ok { + returns.A = hook.MessagesWillBeConsumedWithContext(args.A, args.B) + } else { + return encodableError(fmt.Errorf("hook MessagesWillBeConsumedWithContext called but not implemented")) + } + return nil +} + type Z_LogDebugArgs struct { A string B []any diff --git a/server/public/plugin/hooks.go b/server/public/plugin/hooks.go index 4a677bbe66f0..605199b2ed73 100644 --- a/server/public/plugin/hooks.go +++ b/server/public/plugin/hooks.go @@ -73,6 +73,7 @@ const ( ChannelWillBeRestoredID = 53 ScheduledPostWillBeCreatedID = 54 DraftWillBeUpsertedID = 55 + MessagesWillBeConsumedWithContextID = 56 TotalHooksID = iota ) @@ -199,6 +200,18 @@ type Hooks interface { // Minimum server version: 9.3 MessagesWillBeConsumed(posts []*model.Post) []*model.Post + // MessagesWillBeConsumedWithContext is invoked when messages are requested by a client, before + // they are returned to the client. It is the context-aware variant of MessagesWillBeConsumed. + // + // To modify a post, return the replacement post; the returned posts are matched to the originals + // by ID. Posts that should be left unchanged may be omitted from the returned slice. + // + // Note that this method will be called for posts created by plugins, including the plugin that + // created the post. + // + // Minimum server version: 11.9 + MessagesWillBeConsumedWithContext(c *Context, posts []*model.Post) []*model.Post + // MessageHasBeenDeleted is invoked after the message has been deleted from the database. // Note that this method will be called for posts deleted by plugins, including the plugin that // deleted the post. diff --git a/server/public/plugin/hooks_timer_layer_generated.go b/server/public/plugin/hooks_timer_layer_generated.go index a219828db2b4..0a7ff05c2746 100644 --- a/server/public/plugin/hooks_timer_layer_generated.go +++ b/server/public/plugin/hooks_timer_layer_generated.go @@ -122,6 +122,13 @@ func (hooks *hooksTimerLayer) MessagesWillBeConsumed(posts []*model.Post) []*mod return _returnsA } +func (hooks *hooksTimerLayer) MessagesWillBeConsumedWithContext(c *Context, posts []*model.Post) []*model.Post { + startTime := timePkg.Now() + _returnsA := hooks.hooksImpl.MessagesWillBeConsumedWithContext(c, posts) + hooks.recordTime(startTime, "MessagesWillBeConsumedWithContext", true) + return _returnsA +} + func (hooks *hooksTimerLayer) MessageHasBeenDeleted(c *Context, post *model.Post) { startTime := timePkg.Now() hooks.hooksImpl.MessageHasBeenDeleted(c, post) diff --git a/server/public/plugin/interface_generator/main.go b/server/public/plugin/interface_generator/main.go index 2df8f73a5d5e..ccbab6aa6968 100644 --- a/server/public/plugin/interface_generator/main.go +++ b/server/public/plugin/interface_generator/main.go @@ -38,6 +38,7 @@ var excludedPluginHooks = []string{ "MessageWillBePosted", "MessageWillBeUpdated", "MessagesWillBeConsumed", + "MessagesWillBeConsumedWithContext", "OnActivate", "PluginHTTP", "ServeHTTP", diff --git a/server/public/plugin/plugintest/hooks.go b/server/public/plugin/plugintest/hooks.go index c33ee478943a..4a6f5d530278 100644 --- a/server/public/plugin/plugintest/hooks.go +++ b/server/public/plugin/plugintest/hooks.go @@ -445,6 +445,26 @@ func (_m *Hooks) MessagesWillBeConsumed(posts []*model.Post) []*model.Post { return r0 } +// MessagesWillBeConsumedWithContext provides a mock function with given fields: c, posts +func (_m *Hooks) MessagesWillBeConsumedWithContext(c *plugin.Context, posts []*model.Post) []*model.Post { + ret := _m.Called(c, posts) + + if len(ret) == 0 { + panic("no return value specified for MessagesWillBeConsumedWithContext") + } + + var r0 []*model.Post + if rf, ok := ret.Get(0).(func(*plugin.Context, []*model.Post) []*model.Post); ok { + r0 = rf(c, posts) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.Post) + } + } + + return r0 +} + // NotificationWillBePushed provides a mock function with given fields: pushNotification, userID func (_m *Hooks) NotificationWillBePushed(pushNotification *model.PushNotification, userID string) (*model.PushNotification, string) { ret := _m.Called(pushNotification, userID) From 9defd980928b4635b40bca66ff4cd97d43e13fd7 Mon Sep 17 00:00:00 2001 From: Caleb Roseland Date: Wed, 17 Jun 2026 16:22:06 -0500 Subject: [PATCH 3/3] MM-60617 Add unit tests for admin_console secure_connections (#36484) Co-authored-by: Mattermost Build --- .../secure_connections/controls.test.tsx | 121 ++++++++++ .../modals/modal_utils.test.tsx | 211 ++++++++++++++++++ ...re_connection_accept_invite_modal.test.tsx | 173 ++++++++++++++ ...re_connection_create_invite_modal.test.tsx | 138 ++++++++++++ .../secure_connection_delete_modal.test.tsx | 55 +++++ .../modals/shared_channels_add_modal.test.tsx | 196 ++++++++++++++++ .../shared_channels_remove_modal.test.tsx | 54 +++++ .../secure_connection_detail.test.tsx | 201 +++++++++++++++++ .../secure_connection_row.test.tsx | 191 ++++++++++++++++ .../secure_connections.test.tsx | 89 ++++++++ .../secure_connections/team_selector.test.tsx | 122 ++++++++++ .../secure_connections/utils.test.ts | 61 ++++- webapp/channels/src/utils/test_helper.ts | 20 ++ 13 files changed, 1631 insertions(+), 1 deletion(-) create mode 100644 webapp/channels/src/components/admin_console/secure_connections/controls.test.tsx create mode 100644 webapp/channels/src/components/admin_console/secure_connections/modals/modal_utils.test.tsx create mode 100644 webapp/channels/src/components/admin_console/secure_connections/modals/secure_connection_accept_invite_modal.test.tsx create mode 100644 webapp/channels/src/components/admin_console/secure_connections/modals/secure_connection_create_invite_modal.test.tsx create mode 100644 webapp/channels/src/components/admin_console/secure_connections/modals/secure_connection_delete_modal.test.tsx create mode 100644 webapp/channels/src/components/admin_console/secure_connections/modals/shared_channels_add_modal.test.tsx create mode 100644 webapp/channels/src/components/admin_console/secure_connections/modals/shared_channels_remove_modal.test.tsx create mode 100644 webapp/channels/src/components/admin_console/secure_connections/secure_connection_detail.test.tsx create mode 100644 webapp/channels/src/components/admin_console/secure_connections/secure_connection_row.test.tsx create mode 100644 webapp/channels/src/components/admin_console/secure_connections/secure_connections.test.tsx create mode 100644 webapp/channels/src/components/admin_console/secure_connections/team_selector.test.tsx diff --git a/webapp/channels/src/components/admin_console/secure_connections/controls.test.tsx b/webapp/channels/src/components/admin_console/secure_connections/controls.test.tsx new file mode 100644 index 000000000000..86d3f5ecf60e --- /dev/null +++ b/webapp/channels/src/components/admin_console/secure_connections/controls.test.tsx @@ -0,0 +1,121 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {renderWithContext, screen, userEvent, waitFor} from 'tests/react_testing_utils'; +import {TestHelper} from 'utils/test_helper'; + +import {ConnectionStatusLabel, FormField, ModalFieldset} from './controls'; + +const baseRC = TestHelper.getRemoteClusterMock({ + remote_id: 'rc-1', + name: 'acme', + display_name: 'Acme', + site_url: 'https://siteurl', + last_ping_at: 0, +}); + +describe('ConnectionStatusLabel', () => { + it('renders "Connection Pending" when site_url is pending', () => { + const rc = {...baseRC, site_url: 'pending_https://siteurl'}; + + renderWithContext(); + + expect(screen.getByText('Connection Pending')).toBeInTheDocument(); + }); + + it('renders "Connected" when confirmed and last_ping_at is recent', () => { + const rc = {...baseRC, last_ping_at: Date.now() - 5_000}; + + renderWithContext(); + + expect(screen.getByText('Connected')).toBeInTheDocument(); + }); + + it('renders "Offline" with a last-ping tooltip when confirmed but last_ping_at is stale', async () => { + jest.useFakeTimers(); + const tenMinutesAgo = Date.now() - (10 * 60 * 1000); + const rc = {...baseRC, last_ping_at: tenMinutesAgo}; + + renderWithContext(); + + expect(screen.getByText('Offline')).toBeInTheDocument(); + + await userEvent.hover(screen.getByText('Offline'), {advanceTimers: jest.advanceTimersByTime}); + + await waitFor(() => { + expect(screen.getByText(/Last ping:/)).toBeInTheDocument(); + }); + + jest.useRealTimers(); + }); + + it('renders "Offline" with no tooltip wrapper when last_ping_at is 0', async () => { + jest.useFakeTimers(); + + renderWithContext(); + + expect(screen.getByText('Offline')).toBeInTheDocument(); + + await userEvent.hover(screen.getByText('Offline'), {advanceTimers: jest.advanceTimersByTime}); + jest.advanceTimersByTime(1000); + + expect(screen.queryByText(/Last ping:/)).not.toBeInTheDocument(); + + jest.useRealTimers(); + }); +}); + +describe('FormField', () => { + it('renders the label, children, and helpText', () => { + renderWithContext( + + + , + ); + + expect(screen.getByText('My label')).toBeInTheDocument(); + expect(screen.getByText('Some help text')).toBeInTheDocument(); + expect(screen.getByTestId('child-input')).toBeInTheDocument(); + }); + + it('omits the label and helpText when not provided', () => { + renderWithContext( + + + , + ); + + expect(screen.getByTestId('child-input')).toBeInTheDocument(); + expect(screen.queryByText('My label')).not.toBeInTheDocument(); + expect(screen.queryByText('Some help text')).not.toBeInTheDocument(); + }); +}); + +describe('ModalFieldset', () => { + it('renders the legend and children', () => { + renderWithContext( + + {'inner'} + , + ); + + expect(screen.getByText('Section title')).toBeInTheDocument(); + expect(screen.getByTestId('child')).toHaveTextContent('inner'); + }); + + it('omits the legend when not provided', () => { + renderWithContext( + + {'inner'} + , + ); + + expect(screen.getByTestId('child')).toBeInTheDocument(); + expect(screen.queryByText('Section title')).not.toBeInTheDocument(); + }); +}); diff --git a/webapp/channels/src/components/admin_console/secure_connections/modals/modal_utils.test.tsx b/webapp/channels/src/components/admin_console/secure_connections/modals/modal_utils.test.tsx new file mode 100644 index 000000000000..de4a5c4c931b --- /dev/null +++ b/webapp/channels/src/components/admin_console/secure_connections/modals/modal_utils.test.tsx @@ -0,0 +1,211 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {act} from '@testing-library/react'; + +import type {RemoteClusterPatch} from '@mattermost/types/remote_clusters'; + +import {Client4} from 'mattermost-redux/client'; + +import {renderHookWithContext} from 'tests/react_testing_utils'; +import {ModalIdentifiers} from 'utils/constants'; +import {TestHelper} from 'utils/test_helper'; + +import { + useRemoteClusterAcceptInvite, + useRemoteClusterCreate, + useRemoteClusterCreateInvite, + useRemoteClusterDelete, + useSharedChannelsAdd, + useSharedChannelsRemove, +} from './modal_utils'; + +const mockOpenedModals: any[] = []; + +jest.mock('actions/views/modals', () => ({ + openModal: jest.fn((arg) => { + mockOpenedModals.push(arg); + return {type: 'OPEN_MODAL', arg}; + }), +})); + +const remoteCluster = TestHelper.getRemoteClusterMock({ + remote_id: 'rc-1', + display_name: 'Acme', + name: 'acme', + site_url: 'https://siteurl', +}); + +describe('modal_utils', () => { + beforeEach(() => { + mockOpenedModals.length = 0; + jest.clearAllMocks(); + }); + + describe('useRemoteClusterCreate', () => { + it('opens the create-invite modal in "creating" mode', async () => { + const {result} = renderHookWithContext(() => useRemoteClusterCreate()); + + const patch: RemoteClusterPatch = {display_name: 'Acme'} as RemoteClusterPatch; + act(() => { + result.current.promptCreate(patch); + }); + + expect(mockOpenedModals).toHaveLength(1); + expect(mockOpenedModals[0].modalId).toBe(ModalIdentifiers.SECURE_CONNECTION_CREATE_INVITE); + expect(mockOpenedModals[0].dialogProps.creating).toBe(true); + expect(typeof mockOpenedModals[0].dialogProps.onConfirm).toBe('function'); + }); + }); + + describe('useRemoteClusterCreateInvite', () => { + it('opens the create-invite modal without "creating"', () => { + const {result} = renderHookWithContext(() => useRemoteClusterCreateInvite(remoteCluster)); + + act(() => { + result.current.promptCreateInvite(); + }); + + expect(mockOpenedModals).toHaveLength(1); + expect(mockOpenedModals[0].modalId).toBe(ModalIdentifiers.SECURE_CONNECTION_CREATE_INVITE); + expect(mockOpenedModals[0].dialogProps.creating).toBeUndefined(); + }); + + it('passes an onConfirm that calls Client4.generateInviteRemoteCluster', async () => { + jest.spyOn(Client4, 'generateInviteRemoteCluster').mockResolvedValue('INVITE_TOKEN'); + + const {result} = renderHookWithContext(() => useRemoteClusterCreateInvite(remoteCluster)); + + act(() => { + result.current.promptCreateInvite(); + }); + + const share = await mockOpenedModals[0].dialogProps.onConfirm(); + + expect(Client4.generateInviteRemoteCluster).toHaveBeenCalledWith(remoteCluster.remote_id, expect.objectContaining({password: expect.any(String)})); + expect(share).toEqual({remoteCluster, share: {invite: 'INVITE_TOKEN', password: expect.any(String)}}); + }); + }); + + describe('useRemoteClusterAcceptInvite', () => { + it('opens the accept-invite modal', () => { + const {result} = renderHookWithContext(() => useRemoteClusterAcceptInvite()); + + act(() => { + result.current.promptAcceptInvite(); + }); + + expect(mockOpenedModals).toHaveLength(1); + expect(mockOpenedModals[0].modalId).toBe(ModalIdentifiers.SECURE_CONNECTION_ACCEPT_INVITE); + }); + + it('passes an onConfirm that calls Client4.acceptInviteRemoteCluster', async () => { + const accepted = TestHelper.getRemoteClusterMock({remote_id: 'rc-1', display_name: 'Acme'}); + jest.spyOn(Client4, 'acceptInviteRemoteCluster').mockResolvedValue(accepted); + + const {result} = renderHookWithContext(() => useRemoteClusterAcceptInvite()); + + act(() => { + result.current.promptAcceptInvite(); + }); + + const rc = await mockOpenedModals[0].dialogProps.onConfirm({ + display_name: 'Acme', + default_team_id: 'team-1', + invite: 'INVITE', + password: 'PASSWORD', + }); + + expect(Client4.acceptInviteRemoteCluster).toHaveBeenCalledWith(expect.objectContaining({ + display_name: 'Acme', + default_team_id: 'team-1', + invite: 'INVITE', + password: 'PASSWORD', + name: 'acme', + })); + expect(rc).toBe(accepted); + }); + }); + + describe('useRemoteClusterDelete', () => { + it('opens the delete modal with the cluster display name', () => { + const {result} = renderHookWithContext(() => useRemoteClusterDelete(remoteCluster)); + + act(() => { + result.current.promptDelete(); + }); + + expect(mockOpenedModals).toHaveLength(1); + expect(mockOpenedModals[0].modalId).toBe(ModalIdentifiers.SECURE_CONNECTION_DELETE); + expect(mockOpenedModals[0].dialogProps.displayName).toBe('Acme'); + }); + + it('onConfirm calls Client4.deleteRemoteCluster', async () => { + const spy = jest.spyOn(Client4, 'deleteRemoteCluster').mockResolvedValue({} as any); + + const {result} = renderHookWithContext(() => useRemoteClusterDelete(remoteCluster)); + + act(() => { + result.current.promptDelete(); + }); + + await mockOpenedModals[0].dialogProps.onConfirm(); + + expect(spy).toHaveBeenCalledWith('rc-1'); + }); + }); + + describe('useSharedChannelsRemove', () => { + it('opens the shared-channels-remove modal', async () => { + jest.spyOn(Client4, 'sharedChannelRemoteUninvite').mockResolvedValue({status: 'OK'}); + + const {result} = renderHookWithContext(() => useSharedChannelsRemove('rc-1')); + + act(() => { + result.current.promptRemove('ch-a'); + }); + + expect(mockOpenedModals).toHaveLength(1); + expect(mockOpenedModals[0].modalId).toBe(ModalIdentifiers.SHARED_CHANNEL_REMOTE_UNINVITE); + + await mockOpenedModals[0].dialogProps.onConfirm(); + expect(Client4.sharedChannelRemoteUninvite).toHaveBeenCalledWith('rc-1', 'ch-a'); + }); + }); + + describe('useSharedChannelsAdd', () => { + it('opens the shared-channels-add modal', () => { + const {result} = renderHookWithContext(() => useSharedChannelsAdd('rc-1')); + + act(() => { + result.current.promptAdd(); + }); + + expect(mockOpenedModals).toHaveLength(1); + expect(mockOpenedModals[0].modalId).toBe(ModalIdentifiers.SHARED_CHANNEL_REMOTE_INVITE); + expect(mockOpenedModals[0].dialogProps.remoteId).toBe('rc-1'); + }); + + it('onConfirm aggregates per-channel results and errors', async () => { + const spy = jest.spyOn(Client4, 'sharedChannelRemoteInvite'). + mockResolvedValueOnce({status: 'OK'}). + mockRejectedValueOnce({server_error_id: 'oops'}); + + const {result} = renderHookWithContext(() => useSharedChannelsAdd('rc-1')); + + act(() => { + result.current.promptAdd(); + }); + + const channels = [ + {id: 'ch-ok'}, + {id: 'ch-fail'}, + ] as any; + const res = await mockOpenedModals[0].dialogProps.onConfirm(channels); + + expect(spy).toHaveBeenCalledTimes(2); + expect(res.data['ch-ok']).toBeDefined(); + expect(res.errors['ch-fail']).toEqual({server_error_id: 'oops'}); + }); + }); +}); diff --git a/webapp/channels/src/components/admin_console/secure_connections/modals/secure_connection_accept_invite_modal.test.tsx b/webapp/channels/src/components/admin_console/secure_connections/modals/secure_connection_accept_invite_modal.test.tsx new file mode 100644 index 000000000000..a5598075e936 --- /dev/null +++ b/webapp/channels/src/components/admin_console/secure_connections/modals/secure_connection_accept_invite_modal.test.tsx @@ -0,0 +1,173 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {waitFor} from '@testing-library/react'; +import React from 'react'; + +import {ClientError} from '@mattermost/client'; + +import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils'; +import {TestHelper} from 'utils/test_helper'; + +import SecureConnectionAcceptInviteModal from './secure_connection_accept_invite_modal'; + +jest.mock('../team_selector', () => { + return function MockTeamSelector(props: {testId: string; onChange: (id: string) => void}) { + return ( + + ); + }; +}); + +const remoteCluster = TestHelper.getRemoteClusterMock({ + remote_id: 'rc-1', + display_name: 'Acme', +}); + +const baseState = { + entities: { + teams: { + currentTeamId: 'team-1', + teams: { + 'team-1': TestHelper.getTeamMock({id: 'team-1', display_name: 'Team One'}), + }, + myMembers: { + 'team-1': TestHelper.getTeamMembershipMock({team_id: 'team-1', delete_at: 0}), + }, + }, + }, +}; + +describe('SecureConnectionAcceptInviteModal', () => { + it('renders the title and the four input fields', () => { + renderWithContext( + , + baseState, + ); + + expect(screen.getByText('Accept a connection invite')).toBeInTheDocument(); + expect(screen.getByTestId('display-name')).toBeInTheDocument(); + expect(screen.getByTestId('destination-team-input')).toBeInTheDocument(); + expect(screen.getByTestId('invite-code')).toBeInTheDocument(); + expect(screen.getByTestId('password')).toBeInTheDocument(); + }); + + it('keeps the Accept button disabled when any single field is missing', async () => { + const user = userEvent.setup(); + + renderWithContext( + , + baseState, + ); + + // Fill three of four — omit the password. + await user.type(screen.getByTestId('display-name'), 'Acme Org'); + await user.type(screen.getByTestId('invite-code'), 'INVITE'); + await user.click(screen.getByTestId('destination-team-input')); + + expect(screen.getByRole('button', {name: 'Accept'})).toBeDisabled(); + }); + + it('disables the Accept button until all four fields are filled', async () => { + const user = userEvent.setup(); + + renderWithContext( + , + baseState, + ); + + const accept = screen.getByRole('button', {name: 'Accept'}); + expect(accept).toBeDisabled(); + + await user.type(screen.getByTestId('display-name'), 'Acme Org'); + await user.type(screen.getByTestId('invite-code'), 'INVITE'); + await user.type(screen.getByTestId('password'), 'PASSWORD'); + await user.click(screen.getByTestId('destination-team-input')); + + await waitFor(() => { + expect(screen.getByRole('button', {name: 'Accept'})).toBeEnabled(); + }); + }); + + it('calls onConfirm with the form values when Accept is clicked', async () => { + const user = userEvent.setup(); + const onConfirm = jest.fn().mockResolvedValue(remoteCluster); + const onHide = jest.fn(); + + renderWithContext( + , + baseState, + ); + + await user.type(screen.getByTestId('display-name'), 'Acme Org'); + await user.type(screen.getByTestId('invite-code'), 'INVITE'); + await user.type(screen.getByTestId('password'), 'PASSWORD'); + await user.click(screen.getByTestId('destination-team-input')); + + await user.click(screen.getByRole('button', {name: 'Accept'})); + + await waitFor(() => { + expect(onConfirm).toHaveBeenCalledWith({ + display_name: 'Acme Org', + default_team_id: 'team-1', + invite: 'INVITE', + password: 'PASSWORD', + }); + }); + await waitFor(() => { + expect(onHide).toHaveBeenCalledTimes(1); + }); + }); + + it('shows the error message when onConfirm rejects', async () => { + const user = userEvent.setup(); + const onConfirm = jest.fn().mockRejectedValue(new ClientError('http://localhost', {url: '/x', message: 'denied'})); + + renderWithContext( + , + baseState, + ); + + await user.type(screen.getByTestId('display-name'), 'Acme Org'); + await user.type(screen.getByTestId('invite-code'), 'INVITE'); + await user.type(screen.getByTestId('password'), 'PASSWORD'); + await user.click(screen.getByTestId('destination-team-input')); + + await user.click(screen.getByRole('button', {name: 'Accept'})); + + await waitFor(() => { + expect(screen.getByText('There was an error while accepting the invite.')).toBeInTheDocument(); + }); + }); +}); diff --git a/webapp/channels/src/components/admin_console/secure_connections/modals/secure_connection_create_invite_modal.test.tsx b/webapp/channels/src/components/admin_console/secure_connections/modals/secure_connection_create_invite_modal.test.tsx new file mode 100644 index 000000000000..f45a7047961c --- /dev/null +++ b/webapp/channels/src/components/admin_console/secure_connections/modals/secure_connection_create_invite_modal.test.tsx @@ -0,0 +1,138 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {waitFor} from '@testing-library/react'; +import React from 'react'; + +import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils'; +import {TestHelper} from 'utils/test_helper'; + +import SecureConnectionCreateInviteModal from './secure_connection_create_invite_modal'; + +const remoteCluster = TestHelper.getRemoteClusterMock({ + remote_id: 'rc-1', + display_name: 'Acme', +}); + +const shareResult = { + remoteCluster, + share: {invite: 'INVITE_CODE_XYZ', password: 'pa$$w0rd'}, +}; + +describe('SecureConnectionCreateInviteModal', () => { + it('calls onConfirm on mount and renders the invite + password inputs once resolved', async () => { + const onConfirm = jest.fn().mockResolvedValue(shareResult); + + renderWithContext( + , + ); + + expect(onConfirm).toHaveBeenCalledTimes(1); + + await waitFor(() => { + expect(screen.getByTestId('invite-code')).toHaveValue('INVITE_CODE_XYZ'); + }); + expect(screen.getByTestId('password')).toHaveValue('pa$$w0rd'); + }); + + it('shows the "Connection created" title when creating and resolved', async () => { + const onConfirm = jest.fn().mockResolvedValue(shareResult); + + renderWithContext( + , + ); + + await waitFor(() => { + expect(screen.getByText('Connection created')).toBeInTheDocument(); + }); + }); + + it('shows the default "Invitation code" title when not creating', async () => { + const onConfirm = jest.fn().mockResolvedValue(shareResult); + + renderWithContext( + , + ); + + await waitFor(() => { + expect(screen.getByRole('heading', {name: 'Invitation code'})).toBeInTheDocument(); + }); + }); + + it('flips the confirm button label from "Save" to "Done" once both invite and password are populated', async () => { + let resolveConfirm!: (value: typeof shareResult) => void; + const onConfirm = jest.fn(() => new Promise((resolve) => { + resolveConfirm = resolve; + })); + + renderWithContext( + , + ); + + // Pre-resolve: button shows "Save" (the not-done label) and "Done" is absent. + expect(screen.queryByRole('button', {name: 'Done'})).not.toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Save'})).toBeInTheDocument(); + + // Resolve onConfirm; label flips to "Done". + resolveConfirm(shareResult); + await waitFor(() => { + expect(screen.getByRole('button', {name: 'Done'})).toBeInTheDocument(); + }); + expect(screen.queryByRole('button', {name: 'Save'})).not.toBeInTheDocument(); + }); + + it('renders the security warning notice when done', async () => { + const onConfirm = jest.fn().mockResolvedValue(shareResult); + + renderWithContext( + , + ); + + await waitFor(() => { + expect(screen.getByText('Share these two separately to avoid a security compromise')).toBeInTheDocument(); + }); + }); + + it('does not invoke onConfirm again when the Done button is clicked', async () => { + const onConfirm = jest.fn().mockResolvedValue(shareResult); + const user = userEvent.setup(); + + renderWithContext( + , + ); + + await waitFor(() => { + expect(screen.getByRole('button', {name: 'Done'})).toBeInTheDocument(); + }); + expect(onConfirm).toHaveBeenCalledTimes(1); + + await user.click(screen.getByRole('button', {name: 'Done'})); + + expect(onConfirm).toHaveBeenCalledTimes(1); + }); +}); diff --git a/webapp/channels/src/components/admin_console/secure_connections/modals/secure_connection_delete_modal.test.tsx b/webapp/channels/src/components/admin_console/secure_connections/modals/secure_connection_delete_modal.test.tsx new file mode 100644 index 000000000000..21c9123ed09c --- /dev/null +++ b/webapp/channels/src/components/admin_console/secure_connections/modals/secure_connection_delete_modal.test.tsx @@ -0,0 +1,55 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils'; + +import SecureConnectionDeleteModal from './secure_connection_delete_modal'; + +describe('SecureConnectionDeleteModal', () => { + const baseProps = { + displayName: 'Acme', + onConfirm: jest.fn(), + onCancel: jest.fn(), + onExited: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the title and the displayName in the message', () => { + renderWithContext(); + + expect(screen.getByText('Delete secure connection')).toBeInTheDocument(); + expect(screen.getByText('Acme')).toBeInTheDocument(); + }); + + it('calls onConfirm when the confirm button is clicked', async () => { + const user = userEvent.setup(); + renderWithContext(); + + await user.click(screen.getByRole('button', {name: 'Yes, delete'})); + + expect(baseProps.onConfirm).toHaveBeenCalledTimes(1); + }); + + it('calls onCancel when the cancel button is clicked', async () => { + const user = userEvent.setup(); + renderWithContext(); + + await user.click(screen.getByRole('button', {name: 'Cancel'})); + + expect(baseProps.onCancel).toHaveBeenCalled(); + }); + + it('does not throw when onCancel is omitted', async () => { + const user = userEvent.setup(); + const props = {...baseProps, onCancel: undefined}; + + renderWithContext(); + + await user.click(screen.getByRole('button', {name: 'Cancel'})); + }); +}); diff --git a/webapp/channels/src/components/admin_console/secure_connections/modals/shared_channels_add_modal.test.tsx b/webapp/channels/src/components/admin_console/secure_connections/modals/shared_channels_add_modal.test.tsx new file mode 100644 index 000000000000..e0b69b97f064 --- /dev/null +++ b/webapp/channels/src/components/admin_console/secure_connections/modals/shared_channels_add_modal.test.tsx @@ -0,0 +1,196 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {act, waitFor, within} from '@testing-library/react'; +import React from 'react'; + +import type {ChannelWithTeamData} from '@mattermost/types/channels'; + +import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils'; + +import SharedChannelsAddModal from './shared_channels_add_modal'; + +let mockLastChannelsInputProps: any; + +jest.mock('components/widgets/inputs/channels_input', () => { + return function MockChannelsInput(props: any) { + mockLastChannelsInputProps = props; + return ( +
+ ); + }; +}); + +jest.mock('../utils', () => { + const actual = jest.requireActual('../utils'); + return { + ...actual, + useSharedChannelRemotes: () => [undefined, {loading: false, error: undefined, fetch: jest.fn()}], + }; +}); + +const channelA = {id: 'ch-a', display_name: 'Channel A'} as ChannelWithTeamData; +const channelB = {id: 'ch-b', display_name: 'Channel B'} as ChannelWithTeamData; + +describe('SharedChannelsAddModal', () => { + beforeEach(() => { + mockLastChannelsInputProps = undefined; + }); + + it('renders the title and the channels input', () => { + renderWithContext( + , + ); + + expect(screen.getByText('Select channels')).toBeInTheDocument(); + expect(screen.getByTestId('channels-input')).toBeInTheDocument(); + }); + + it('disables the Share button until channels are selected', async () => { + renderWithContext( + , + ); + + expect(screen.getByRole('button', {name: 'Share'})).toBeDisabled(); + + act(() => { + mockLastChannelsInputProps.onChange([channelA, channelB]); + }); + + await waitFor(() => { + expect(screen.getByRole('button', {name: 'Share'})).toBeEnabled(); + }); + }); + + it('calls onConfirm with selected channels and closes when there are no errors', async () => { + const user = userEvent.setup(); + const onConfirm = jest.fn().mockResolvedValue({data: {}, errors: {}}); + const onHide = jest.fn(); + + renderWithContext( + , + ); + + act(() => { + mockLastChannelsInputProps.onChange([channelA]); + }); + + await waitFor(() => { + expect(screen.getByRole('button', {name: 'Share'})).toBeEnabled(); + }); + + await user.click(screen.getByRole('button', {name: 'Share'})); + + await waitFor(() => { + expect(onConfirm).toHaveBeenCalledWith([channelA]); + }); + await waitFor(() => { + expect(onHide).toHaveBeenCalledTimes(1); + }); + }); + + it('renders error notices and switches confirm label to Close when onConfirm returns errors', async () => { + const user = userEvent.setup(); + const onConfirm = jest.fn().mockResolvedValue({ + data: {}, + errors: { + 'ch-a': {server_error_id: 'api.command_share.channel_invite_not_home.error', message: 'nope'}, + }, + }); + + renderWithContext( + , + ); + + act(() => { + mockLastChannelsInputProps.onChange([channelA]); + }); + + await waitFor(() => { + expect(screen.getByRole('button', {name: 'Share'})).toBeEnabled(); + }); + + await user.click(screen.getByRole('button', {name: 'Share'})); + + await waitFor(() => { + expect(screen.getByText(/could not be added to this connection/)).toBeInTheDocument(); + }); + + const dialog = screen.getByRole('dialog'); + expect(within(dialog).queryByRole('button', {name: 'Share'})).not.toBeInTheDocument(); + + // The dialog has both a header dismiss button (aria-label="Close") and the + // footer confirm button (text "Close" after the flip), so role+name alone + // matches two elements. Scope the confirm-label assertion to the footer. + const footer = dialog.querySelector('.modal-footer') as HTMLElement; + expect(within(footer).getByRole('button', {name: 'Close'})).toBeInTheDocument(); + }); + + it('drops errors for channels removed from the selection', async () => { + const user = userEvent.setup(); + const onConfirm = jest.fn().mockResolvedValue({ + data: {}, + errors: { + 'ch-a': {server_error_id: 'some.error', message: 'nope'}, + }, + }); + + renderWithContext( + , + ); + + act(() => { + mockLastChannelsInputProps.onChange([channelA]); + }); + + await waitFor(() => { + expect(screen.getByRole('button', {name: 'Share'})).toBeEnabled(); + }); + await user.click(screen.getByRole('button', {name: 'Share'})); + + await waitFor(() => { + expect(screen.getByText(/could not be added/)).toBeInTheDocument(); + }); + + act(() => { + mockLastChannelsInputProps.onChange([channelB]); + }); + + await waitFor(() => { + expect(screen.queryByText(/could not be added/)).not.toBeInTheDocument(); + }); + }); +}); diff --git a/webapp/channels/src/components/admin_console/secure_connections/modals/shared_channels_remove_modal.test.tsx b/webapp/channels/src/components/admin_console/secure_connections/modals/shared_channels_remove_modal.test.tsx new file mode 100644 index 000000000000..170c0b964cb8 --- /dev/null +++ b/webapp/channels/src/components/admin_console/secure_connections/modals/shared_channels_remove_modal.test.tsx @@ -0,0 +1,54 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils'; + +import SharedChannelsRemoveModal from './shared_channels_remove_modal'; + +describe('SharedChannelsRemoveModal', () => { + const baseProps = { + onConfirm: jest.fn(), + onCancel: jest.fn(), + onExited: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the title and subheader copy', () => { + renderWithContext(); + + expect(screen.getByText('Remove channel')).toBeInTheDocument(); + expect(screen.getByText('The channel will be removed from this connection and will no longer be shared with it.')).toBeInTheDocument(); + }); + + it('calls onConfirm when the Remove button is clicked', async () => { + const user = userEvent.setup(); + renderWithContext(); + + await user.click(screen.getByRole('button', {name: 'Remove'})); + + expect(baseProps.onConfirm).toHaveBeenCalledTimes(1); + }); + + it('calls onCancel when the cancel button is clicked', async () => { + const user = userEvent.setup(); + renderWithContext(); + + await user.click(screen.getByRole('button', {name: 'Cancel'})); + + expect(baseProps.onCancel).toHaveBeenCalled(); + }); + + it('does not throw when onCancel is omitted', async () => { + const user = userEvent.setup(); + const props = {...baseProps, onCancel: undefined}; + + renderWithContext(); + + await user.click(screen.getByRole('button', {name: 'Cancel'})); + }); +}); diff --git a/webapp/channels/src/components/admin_console/secure_connections/secure_connection_detail.test.tsx b/webapp/channels/src/components/admin_console/secure_connections/secure_connection_detail.test.tsx new file mode 100644 index 000000000000..bb70a1dbcd6e --- /dev/null +++ b/webapp/channels/src/components/admin_console/secure_connections/secure_connection_detail.test.tsx @@ -0,0 +1,201 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {waitFor} from '@testing-library/react'; +import {createMemoryHistory} from 'history'; +import React from 'react'; +import {Route} from 'react-router-dom'; + +import {Client4} from 'mattermost-redux/client'; + +import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils'; +import {TestHelper} from 'utils/test_helper'; + +import SecureConnectionDetail from './secure_connection_detail'; + +const mockPromptCreate = jest.fn(); +const mockHistoryReplace = jest.fn(); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useHistory: () => ({replace: mockHistoryReplace, push: jest.fn()}), +})); + +jest.mock('./chat.svg', () => () => ); + +jest.mock('./team_selector', () => { + return function MockTeamSelector(props: {testId: string; onChange: (teamId: string) => void}) { + return ( + + ); + }; +}); + +jest.mock('./modals/modal_utils', () => ({ + useRemoteClusterCreate: () => ({promptCreate: mockPromptCreate, saving: false}), + useSharedChannelsAdd: () => ({promptAdd: jest.fn().mockResolvedValue(undefined)}), + useSharedChannelsRemove: () => ({promptRemove: jest.fn().mockResolvedValue(undefined)}), +})); + +const team = TestHelper.getTeamMock({id: 'team-1', display_name: 'Team One'}); +const teamMembership = TestHelper.getTeamMembershipMock({team_id: 'team-1', delete_at: 0}); + +const baseState = { + entities: { + teams: { + currentTeamId: 'team-1', + teams: {'team-1': team}, + myMembers: {'team-1': teamMembership}, + }, + channels: { + channels: {}, + channelsInTeam: {}, + }, + }, +}; + +const remoteCluster = TestHelper.getRemoteClusterMock({ + remote_id: 'rc-1', + display_name: 'Acme', + name: 'acme', + site_url: 'https://acme.example.com', + last_ping_at: Date.now() - 5_000, + default_team_id: 'team-1', +}); + +function renderAtPath(path: string, state: any = baseState) { + return renderWithContext( + + + , + state, + {history: createMemoryHistory({initialEntries: [path]})}, + ); +} + +describe('SecureConnectionDetail', () => { + let getRemoteCluster: jest.SpyInstance; + let getSharedChannelRemotes: jest.SpyInstance; + + beforeEach(() => { + getRemoteCluster = jest.spyOn(Client4, 'getRemoteCluster').mockResolvedValue(remoteCluster); + getSharedChannelRemotes = jest.spyOn(Client4, 'getSharedChannelRemotes').mockResolvedValue([]); + mockPromptCreate.mockReset(); + mockPromptCreate.mockResolvedValue(undefined); + mockHistoryReplace.mockClear(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('renders the page title and back link', async () => { + const {container} = renderAtPath('/admin_console/site_config/secure_connections/rc-1'); + + expect(screen.getByText('Connection Configuration')).toBeInTheDocument(); + expect(screen.getByText('Connection Details')).toBeInTheDocument(); + + const backLink = container.querySelector('a[href="/admin_console/site_config/secure_connections"].back'); + expect(backLink).toBeInTheDocument(); + }); + + it('shows a loading state while the cluster is being fetched', () => { + getRemoteCluster.mockImplementation(() => new Promise(() => {})); + + renderAtPath('/admin_console/site_config/secure_connections/rc-1'); + + expect(screen.getAllByText('Loading').length).toBeGreaterThan(0); + }); + + it('renders the org name input pre-filled when editing', async () => { + renderAtPath('/admin_console/site_config/secure_connections/rc-1'); + + await waitFor(() => { + expect(screen.getByTestId('organization-name-input')).toHaveValue('Acme'); + }); + expect(screen.getByText('Connected')).toBeInTheDocument(); + }); + + it('renders an empty org name input in create mode', () => { + renderAtPath('/admin_console/site_config/secure_connections/create'); + + expect(screen.getByTestId('organization-name-input')).toHaveValue(''); + }); + + it('hides the shared channels section in create mode', () => { + renderAtPath('/admin_console/site_config/secure_connections/create'); + + expect(screen.queryByText('Shared Channels')).not.toBeInTheDocument(); + }); + + it('shows the shared channels section in edit mode', async () => { + renderAtPath('/admin_console/site_config/secure_connections/rc-1'); + + await waitFor(() => { + expect(screen.getByText('Shared Channels')).toBeInTheDocument(); + }); + expect(screen.getByRole('button', {name: /Add channels/})).toBeInTheDocument(); + }); + + it('typing in the org name input enables the save panel', async () => { + const user = userEvent.setup(); + renderAtPath('/admin_console/site_config/secure_connections/rc-1'); + + await waitFor(() => { + expect(screen.getByTestId('organization-name-input')).toHaveValue('Acme'); + }); + + const saveButton = screen.getByRole('button', {name: 'Save'}); + expect(saveButton).toBeDisabled(); + + const input = screen.getByTestId('organization-name-input'); + await user.clear(input); + await user.type(input, 'Acme Renamed'); + + expect(screen.getByTestId('organization-name-input')).toHaveValue('Acme Renamed'); + + await waitFor(() => { + expect(screen.getByRole('button', {name: 'Save'})).toBeEnabled(); + }); + }); + + it('renders the placeholder when no shared channels exist (edit mode, confirmed)', async () => { + renderAtPath('/admin_console/site_config/secure_connections/rc-1'); + + await waitFor(() => { + expect(screen.getByTestId('chat-svg')).toBeInTheDocument(); + }); + expect(getSharedChannelRemotes).toHaveBeenCalled(); + }); + + it('navigates to the new connection edit page after a successful create', async () => { + const user = userEvent.setup(); + const created = TestHelper.getRemoteClusterMock({remote_id: 'rc-new', display_name: 'New Org', default_team_id: 'team-1'}); + mockPromptCreate.mockResolvedValueOnce(created); + + renderAtPath('/admin_console/site_config/secure_connections/create'); + + await user.type(screen.getByTestId('organization-name-input'), 'New Org'); + await user.click(screen.getByTestId('destination-team-input')); + + await waitFor(() => { + expect(screen.getByRole('button', {name: 'Save'})).toBeEnabled(); + }); + await user.click(screen.getByRole('button', {name: 'Save'})); + + await waitFor(() => { + expect(mockPromptCreate).toHaveBeenCalledWith({display_name: 'New Org', default_team_id: 'team-1'}); + }); + await waitFor(() => { + expect(mockHistoryReplace).toHaveBeenCalledWith(expect.objectContaining({ + pathname: '/admin_console/site_config/secure_connections/rc-new', + })); + }); + }); +}); diff --git a/webapp/channels/src/components/admin_console/secure_connections/secure_connection_row.test.tsx b/webapp/channels/src/components/admin_console/secure_connections/secure_connection_row.test.tsx new file mode 100644 index 000000000000..ca85a200415e --- /dev/null +++ b/webapp/channels/src/components/admin_console/secure_connections/secure_connection_row.test.tsx @@ -0,0 +1,191 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {waitFor} from '@testing-library/react'; +import React from 'react'; + +import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils'; +import {TestHelper} from 'utils/test_helper'; + +import SecureConnectionRow from './secure_connection_row'; + +jest.mock('./modals/modal_utils', () => ({ + useRemoteClusterDelete: jest.fn(), + useRemoteClusterCreateInvite: jest.fn(), +})); + +const {useRemoteClusterDelete, useRemoteClusterCreateInvite} = jest.requireMock('./modals/modal_utils'); + +const promptDelete = jest.fn(); +const promptCreateInvite = jest.fn(); + +const confirmedRC = TestHelper.getRemoteClusterMock({ + remote_id: 'rc-1', + display_name: 'Acme', + name: 'acme', + site_url: 'https://siteurl', + last_ping_at: 0, +}); + +const pendingRC = { + ...confirmedRC, + site_url: 'pending_https://siteurl', +}; + +describe('SecureConnectionRow', () => { + beforeEach(() => { + jest.clearAllMocks(); + promptDelete.mockResolvedValue(undefined); + promptCreateInvite.mockResolvedValue(undefined); + useRemoteClusterDelete.mockReturnValue({promptDelete}); + useRemoteClusterCreateInvite.mockReturnValue({promptCreateInvite}); + }); + + it('renders the connection display name and a status label', () => { + renderWithContext( + , + ); + + expect(screen.getByText('Acme')).toBeInTheDocument(); + expect(screen.getByText('Offline')).toBeInTheDocument(); + }); + + it('shows "Connection Pending" for unconfirmed clusters', () => { + renderWithContext( + , + ); + + expect(screen.getByText('Connection Pending')).toBeInTheDocument(); + }); + + it('shows "Generate invitation code" when the connection is not yet confirmed', async () => { + const user = userEvent.setup(); + + renderWithContext( + , + ); + + await user.click(screen.getByLabelText(/Connection options for/)); + + expect(screen.getByRole('menuitem', {name: 'Generate invitation code'})).toBeInTheDocument(); + }); + + it('clicking "Generate invitation code" opens the create-invite prompt', async () => { + const user = userEvent.setup(); + + renderWithContext( + , + ); + + await user.click(screen.getByLabelText(/Connection options for/)); + await user.click(screen.getByRole('menuitem', {name: 'Generate invitation code'})); + + await waitFor(() => { + expect(promptCreateInvite).toHaveBeenCalledTimes(1); + }); + }); + + it('hides "Generate invitation code" when the connection is confirmed', async () => { + const user = userEvent.setup(); + + renderWithContext( + , + ); + + await user.click(screen.getByLabelText(/Connection options for/)); + + expect(screen.queryByRole('menuitem', {name: 'Generate invitation code'})).not.toBeInTheDocument(); + expect(screen.getByRole('menuitem', {name: 'Edit'})).toBeInTheDocument(); + }); + + it('passes the remoteCluster into useRemoteClusterDelete', () => { + renderWithContext( + , + ); + + expect(useRemoteClusterDelete).toHaveBeenCalledWith(confirmedRC); + expect(useRemoteClusterCreateInvite).toHaveBeenCalledWith(confirmedRC); + }); + + it('clicking "Delete" calls onDeleteSuccess after promptDelete resolves', async () => { + const user = userEvent.setup(); + const onDeleteSuccess = jest.fn(); + + renderWithContext( + , + ); + + await user.click(screen.getByLabelText(/Connection options for/)); + await user.click(screen.getByRole('menuitem', {name: 'Delete'})); + + await waitFor(() => { + expect(promptDelete).toHaveBeenCalledTimes(1); + expect(onDeleteSuccess).toHaveBeenCalled(); + }); + }); + + it('does NOT call onDeleteSuccess when the user cancels the delete prompt', async () => { + const user = userEvent.setup(); + const onDeleteSuccess = jest.fn(); + + // Cancellation in the real prompt leaves the promise pending forever + // (the modal closes without resolving or rejecting). + promptDelete.mockReturnValueOnce(new Promise(() => {})); + + renderWithContext( + , + ); + + await user.click(screen.getByLabelText(/Connection options for/)); + await user.click(screen.getByRole('menuitem', {name: 'Delete'})); + + await waitFor(() => { + expect(promptDelete).toHaveBeenCalledTimes(1); + }); + expect(onDeleteSuccess).not.toHaveBeenCalled(); + }); + + it('disables the menu button when disabled', () => { + renderWithContext( + , + ); + + expect(screen.getByLabelText(/Connection options for/)).toBeDisabled(); + }); +}); diff --git a/webapp/channels/src/components/admin_console/secure_connections/secure_connections.test.tsx b/webapp/channels/src/components/admin_console/secure_connections/secure_connections.test.tsx new file mode 100644 index 000000000000..3a2ed175d5d4 --- /dev/null +++ b/webapp/channels/src/components/admin_console/secure_connections/secure_connections.test.tsx @@ -0,0 +1,89 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {waitFor} from '@testing-library/react'; +import React from 'react'; + +import type {RemoteCluster} from '@mattermost/types/remote_clusters'; + +import {Client4} from 'mattermost-redux/client'; + +import {renderWithContext, screen} from 'tests/react_testing_utils'; +import {TestHelper} from 'utils/test_helper'; + +import SecureConnections from './secure_connections'; + +jest.mock('./building.svg', () => () => ); + +jest.mock('./modals/modal_utils', () => ({ + useRemoteClusterAcceptInvite: () => ({promptAcceptInvite: jest.fn().mockResolvedValue(undefined)}), + useRemoteClusterDelete: () => ({promptDelete: jest.fn().mockResolvedValue(undefined)}), + useRemoteClusterCreateInvite: () => ({promptCreateInvite: jest.fn().mockResolvedValue(undefined)}), +})); + +const sampleClusters: RemoteCluster[] = [ + TestHelper.getRemoteClusterMock({remote_id: 'rc-1', display_name: 'Acme', name: 'acme', site_url: 'https://acme', last_ping_at: 0}), + TestHelper.getRemoteClusterMock({remote_id: 'rc-2', display_name: 'Beta', name: 'beta', site_url: 'https://beta', last_ping_at: 0}), +]; + +describe('SecureConnections', () => { + let getRemoteClusters: jest.SpyInstance; + + beforeEach(() => { + getRemoteClusters = jest.spyOn(Client4, 'getRemoteClusters'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('shows the LoadingScreen while remote clusters are loading', async () => { + getRemoteClusters.mockImplementation(() => new Promise(() => {})); + + renderWithContext(); + + expect(screen.getByText('Loading')).toBeInTheDocument(); + }); + + it('renders the placeholder when no remote clusters exist', async () => { + getRemoteClusters.mockResolvedValue([]); + + renderWithContext(); + + await waitFor(() => { + expect(screen.getByRole('heading', {name: 'Share channels'})).toBeInTheDocument(); + }); + expect(screen.getByText('Connecting with an external workspace allows you to share channels with them')).toBeInTheDocument(); + }); + + it('renders one row per remote cluster with their display names', async () => { + getRemoteClusters.mockResolvedValue(sampleClusters); + + renderWithContext(); + + await waitFor(() => { + expect(screen.getByText('Acme')).toBeInTheDocument(); + }); + expect(screen.getByText('Beta')).toBeInTheDocument(); + }); + + it('shows the "service not running" notice when the API returns the service-not-enabled error', async () => { + getRemoteClusters.mockRejectedValue(Object.assign(new Error('disabled'), {server_error_id: 'api.remote_cluster.service_not_enabled.app_error'})); + + renderWithContext(); + + await waitFor(() => { + expect(screen.getByText('Service not running, please restart server.')).toBeInTheDocument(); + }); + }); + + it('renders the "Add a connection" button', async () => { + getRemoteClusters.mockResolvedValue([]); + + renderWithContext(); + + await waitFor(() => { + expect(screen.getAllByText('Add a connection').length).toBeGreaterThan(0); + }); + }); +}); diff --git a/webapp/channels/src/components/admin_console/secure_connections/team_selector.test.tsx b/webapp/channels/src/components/admin_console/secure_connections/team_selector.test.tsx new file mode 100644 index 000000000000..cd8f9b1613ed --- /dev/null +++ b/webapp/channels/src/components/admin_console/secure_connections/team_selector.test.tsx @@ -0,0 +1,122 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import type {Team} from '@mattermost/types/teams'; +import type {IDMappedObjects} from '@mattermost/types/utilities'; + +import {renderWithContext} from 'tests/react_testing_utils'; +import {TestHelper} from 'utils/test_helper'; + +import TeamSelector from './team_selector'; + +let mockLastDropdownProps: any; + +jest.mock('components/dropdown_input', () => { + return function MockDropdownInput(props: any) { + mockLastDropdownProps = props; + return ( +
+ ); + }; +}); + +const teamA: Team = TestHelper.getTeamMock({id: 'team-a', display_name: 'Charlie'}); +const teamB: Team = TestHelper.getTeamMock({id: 'team-b', display_name: 'Alpha'}); +const teamC: Team = TestHelper.getTeamMock({id: 'team-c', display_name: 'Bravo'}); + +const teamsById: IDMappedObjects = { + [teamA.id]: teamA, + [teamB.id]: teamB, + [teamC.id]: teamC, +}; + +describe('TeamSelector', () => { + beforeEach(() => { + mockLastDropdownProps = undefined; + }); + + it('passes teams sorted by display_name as DropdownInput options', () => { + renderWithContext( + , + ); + + expect(mockLastDropdownProps.options).toEqual([ + {value: teamB.id, label: 'Alpha'}, + {value: teamC.id, label: 'Bravo'}, + {value: teamA.id, label: 'Charlie'}, + ]); + }); + + it('passes the matching team as the current value', () => { + renderWithContext( + , + ); + + expect(mockLastDropdownProps.value).toEqual({label: 'Charlie', value: teamA.id}); + }); + + it('passes undefined as value when the id does not match a team', () => { + renderWithContext( + , + ); + + expect(mockLastDropdownProps.value).toBeUndefined(); + }); + + it('forwards the legend, testId, and required flag', () => { + renderWithContext( + , + ); + + expect(mockLastDropdownProps.testId).toBe('destination-team'); + expect(mockLastDropdownProps.legend).toBe('Pick a team'); + expect(mockLastDropdownProps.required).toBe(true); + expect(mockLastDropdownProps.name).toBe('team_selector'); + }); + + it('invokes onChange with the chosen team id', () => { + const onChange = jest.fn(); + + renderWithContext( + , + ); + + mockLastDropdownProps.onChange({value: teamC.id, label: 'Bravo'}); + + expect(onChange).toHaveBeenCalledWith(teamC.id); + }); +}); diff --git a/webapp/channels/src/components/admin_console/secure_connections/utils.test.ts b/webapp/channels/src/components/admin_console/secure_connections/utils.test.ts index dd0b51b0c24b..c73ceb9f0124 100644 --- a/webapp/channels/src/components/admin_console/secure_connections/utils.test.ts +++ b/webapp/channels/src/components/admin_console/secure_connections/utils.test.ts @@ -3,7 +3,13 @@ import type {RemoteCluster} from '@mattermost/types/remote_clusters'; -import {isConfirmed} from './utils'; +import { + getCreateLocation, + getEditLocation, + isConfirmed, + isErrorState, + isPendingState, +} from './utils'; describe('isConfirmed', () => { it('should return true', () => { @@ -16,3 +22,56 @@ describe('isConfirmed', () => { expect(confirmed).toBe(false); }); }); + +describe('getEditLocation', () => { + it('returns the edit path for the given remote cluster and carries it as state', () => { + const rc = {remote_id: 'abc123', display_name: 'Acme'} as RemoteCluster; + + const location = getEditLocation(rc); + + expect(location).toEqual({ + pathname: '/admin_console/site_config/secure_connections/abc123', + state: rc, + }); + }); +}); + +describe('getCreateLocation', () => { + it('returns the create path', () => { + const location = getCreateLocation(); + + expect(location).toEqual({ + pathname: '/admin_console/site_config/secure_connections/create', + }); + }); +}); + +describe('isPendingState', () => { + it('returns true only when loading state is exactly true', () => { + expect(isPendingState(true)).toBe(true); + }); + + it('returns false when loading state is false', () => { + expect(isPendingState(false)).toBe(false); + }); + + it('returns false when loading state is an Error', () => { + const err = new Error('boom'); + expect(isPendingState(err)).toBe(false); + }); +}); + +describe('isErrorState', () => { + it('returns true for an Error instance', () => { + const err = new Error('boom'); + expect(isErrorState(err)).toBe(true); + }); + + it('returns false for boolean true (still loading)', () => { + expect(isErrorState(true)).toBe(false); + }); + + it('returns false for boolean false (idle)', () => { + expect(isErrorState(false)).toBe(false); + }); +}); diff --git a/webapp/channels/src/utils/test_helper.ts b/webapp/channels/src/utils/test_helper.ts index 6cdf21f1957a..ceaed7ef981c 100644 --- a/webapp/channels/src/utils/test_helper.ts +++ b/webapp/channels/src/utils/test_helper.ts @@ -14,6 +14,7 @@ import type {Command, IncomingWebhook, OutgoingWebhook} from '@mattermost/types/ import type {Post} from '@mattermost/types/posts'; import type {PreferenceType} from '@mattermost/types/preferences'; import type {Reaction} from '@mattermost/types/reactions'; +import type {RemoteCluster} from '@mattermost/types/remote_clusters'; import type {Role} from '@mattermost/types/roles'; import type {Session} from '@mattermost/types/sessions'; import type {Team, TeamMembership} from '@mattermost/types/teams'; @@ -244,6 +245,25 @@ export class TestHelper { return Object.assign({}, defaultMembership, override); } + public static getRemoteClusterMock(override?: Partial): RemoteCluster { + const defaultRemoteCluster: RemoteCluster = { + remote_id: 'remote_id', + remote_team_id: 'remote_team_id', + name: 'remote_name', + display_name: 'Remote Name', + site_url: 'https://example.com', + create_at: 0, + delete_at: 0, + last_ping_at: 0, + topics: '', + creator_id: 'creator_id', + plugin_id: '', + options: 0, + default_team_id: 'team_id', + }; + return Object.assign({}, defaultRemoteCluster, override); + } + public static getRoleMock(override: Partial = {}): Role { const defaultRole: Role = { id: 'role_id',