From 62056e5a7c6c817fe95e4edeaeee8af8fb6b0b7d Mon Sep 17 00:00:00 2001 From: Nick Misasi Date: Thu, 20 Aug 2026 12:11:21 -0400 Subject: [PATCH] MM-70071: Automatically select hosted push notification server based on license (#37802) * MM-70071: Automatically select hosted push notification server based on license Co-authored-by: nick.misasi * test: fix mock-store fallout from push endpoint license listener Co-authored-by: nick.misasi * MM-70071: address review feedback on push endpoint sync Co-authored-by: nick.misasi * ci: retrigger enterprise tests against updated companion branch Co-authored-by: nick.misasi * ci: retrigger flaky artifact build Co-authored-by: nick.misasi * MM-70071: revert any hosted push endpoint to test on entitlement loss Co-authored-by: nick.misasi * MM-70071: add nil-safe License.HasMHPNS entitlement check Co-authored-by: nick.misasi * MM-70071: drop preview-tree docs for auto-selected push server Monorepo MDX is still unpublished; this belongs in mattermost/docs. Co-authored-by: Cursor * MM-70071: stub InitEmailBatching on guest-invite email mocks License teardown now SaveConfigs the push endpoint, which fires the existing email-batching config listener. Co-authored-by: Cursor * MM-70071: don't re-init email batching on push-server license sync License teardown SaveConfigs the push endpoint, which fired the existing email-batching listener and panicked tests that mock EmailService. Re-init batching only when EnableEmailBatching changes, and stub the remaining invite mock used during helper cleanup. Co-authored-by: Nick Misasi * MM-70071: re-init email batching when the interval setting changes Keep EmailBatchingInterval live at runtime; only ignore unrelated config writes such as the push-server license sync. Co-authored-by: Nick Misasi * MM-70071: isolate mock tests from push endpoint sync Use custom push endpoints in shared mock fixtures so unrelated tests do not need config-listener expectations. Co-authored-by: Cursor --------- Co-authored-by: Cursor Agent Co-authored-by: Mattermost Build --- server/channels/api4/apitestlib.go | 7 +- server/channels/api4/channel_test.go | 10 + server/channels/app/helper_test.go | 8 +- server/channels/app/notification.go | 10 +- .../channels/app/push_notification_server.go | 79 +++++++ .../app/push_notification_server_test.go | 192 ++++++++++++++++++ server/channels/app/server.go | 34 +++- server/channels/app/server_test.go | 47 +++++ server/channels/app/team_test.go | 6 +- server/public/model/audit_events.go | 19 +- server/public/model/license.go | 5 + server/public/model/license_test.go | 50 +++++ server/public/model/push_notification.go | 14 ++ server/public/model/push_notification_test.go | 26 +++ 14 files changed, 482 insertions(+), 25 deletions(-) create mode 100644 server/channels/app/push_notification_server.go create mode 100644 server/channels/app/push_notification_server_test.go diff --git a/server/channels/api4/apitestlib.go b/server/channels/api4/apitestlib.go index 845812288fd..cf6ce88db35 100644 --- a/server/channels/api4/apitestlib.go +++ b/server/channels/api4/apitestlib.go @@ -370,7 +370,7 @@ func SetupConfigWithStoreMock(tb testing.TB, updateConfig func(cfg *model.Config } func SetupWithStoreMock(tb testing.TB) *TestHelper { - th := setupTestHelper(tb, testlib.GetMockStoreForSetupFunctions(), nil, nil, false, false, nil, nil) + th := setupTestHelper(tb, testlib.GetMockStoreForSetupFunctions(), nil, nil, false, false, useCustomPushNotificationServer, nil) statusMock := mocks.StatusStore{} statusMock.On("UpdateExpiredDNDStatuses").Return([]*model.Status{}, nil) statusMock.On("Get", "user1").Return(&model.Status{UserId: "user1", Status: model.StatusOnline}, nil) @@ -383,10 +383,15 @@ func SetupWithStoreMock(tb testing.TB) *TestHelper { return th } +func useCustomPushNotificationServer(config *model.Config) { + *config.EmailSettings.PushNotificationServer = "https://push.example.com" +} + func SetupEnterpriseWithStoreMock(tb testing.TB, options ...app.Option) *TestHelper { removeSpuriousErrors := func(config *model.Config) { // If not set, you will receive an unactionable error in the console *config.ServiceSettings.SiteURL = "http://localhost:8065" + useCustomPushNotificationServer(config) } th := setupTestHelper(tb, testlib.GetMockStoreForSetupFunctions(), nil, nil, true, false, removeSpuriousErrors, options) diff --git a/server/channels/api4/channel_test.go b/server/channels/api4/channel_test.go index 2be8d2ff74b..b95a4ddda16 100644 --- a/server/channels/api4/channel_test.go +++ b/server/channels/api4/channel_test.go @@ -6878,6 +6878,11 @@ func TestGetChannelModerations(t *testing.T) { scheme := th.SetupTeamScheme(t) scheme.DefaultChannelGuestRole = "" + // Restore the real store so helper cleanup (cache invalidation, license reload) + // doesn't run against the partial mock. + originalStore := th.App.Srv().Store() + t.Cleanup(func() { th.App.Srv().SetStore(originalStore) }) + mockStore := mocks.Store{} // Playbooks DB job requires a plugin mock @@ -7034,6 +7039,11 @@ func TestPatchChannelModerations(t *testing.T) { scheme := th.SetupTeamScheme(t) scheme.DefaultChannelGuestRole = "" + // Restore the real store so helper cleanup (cache invalidation, license reload) + // doesn't run against the partial mock. + originalStore := th.App.Srv().Store() + t.Cleanup(func() { th.App.Srv().SetStore(originalStore) }) + mockStore := mocks.Store{} // Playbooks DB job requires a plugin mock diff --git a/server/channels/app/helper_test.go b/server/channels/app/helper_test.go index 7e53b889d8a..611b806bdc0 100644 --- a/server/channels/app/helper_test.go +++ b/server/channels/app/helper_test.go @@ -261,9 +261,13 @@ func SetupWithoutPreloadMigrations(tb testing.TB) *TestHelper { return setupTestHelper(dbStore, mainHelper.GetSQLStore(), mainHelper.GetSQLSettings(), mainHelper.GetSearchEngine(), false, true, nil, nil, tb) } +func useCustomPushNotificationServer(cfg *model.Config) { + *cfg.EmailSettings.PushNotificationServer = "https://push.example.com" +} + func SetupWithStoreMock(tb testing.TB) *TestHelper { mockStore := testlib.GetMockStoreForSetupFunctions() - th := setupTestHelper(mockStore, mainHelper.GetSQLStore(), mainHelper.GetSQLSettings(), mainHelper.GetSearchEngine(), false, false, nil, nil, tb) + th := setupTestHelper(mockStore, mainHelper.GetSQLStore(), mainHelper.GetSQLSettings(), mainHelper.GetSearchEngine(), false, false, useCustomPushNotificationServer, nil, tb) statusMock := mocks.StatusStore{} statusMock.On("UpdateExpiredDNDStatuses").Return([]*model.Status{}, nil) statusMock.On("Get", "user1").Return(&model.Status{UserId: "user1", Status: model.StatusOnline}, nil) @@ -284,7 +288,7 @@ func SetupWithStoreMock(tb testing.TB) *TestHelper { func SetupEnterpriseWithStoreMock(tb testing.TB) *TestHelper { mockStore := testlib.GetMockStoreForSetupFunctions() - th := setupTestHelper(mockStore, mainHelper.GetSQLStore(), mainHelper.GetSQLSettings(), mainHelper.GetSearchEngine(), true, false, nil, nil, tb) + th := setupTestHelper(mockStore, mainHelper.GetSQLStore(), mainHelper.GetSQLSettings(), mainHelper.GetSearchEngine(), true, false, useCustomPushNotificationServer, nil, tb) statusMock := mocks.StatusStore{} statusMock.On("UpdateExpiredDNDStatuses").Return([]*model.Status{}, nil) statusMock.On("Get", "user1").Return(&model.Status{UserId: "user1", Status: model.StatusOnline}, nil) diff --git a/server/channels/app/notification.go b/server/channels/app/notification.go index 4efe985718d..06562479396 100644 --- a/server/channels/app/notification.go +++ b/server/channels/app/notification.go @@ -30,15 +30,7 @@ func (a *App) canSendPushNotifications() bool { } pushServer := *a.Config().EmailSettings.PushNotificationServer - // Check for MHPNS servers (both current and legacy DNS aliases) - isMHPNSServer := pushServer == model.MHPNS || - pushServer == model.MHPNSLegacyUS || - pushServer == model.MHPNSLegacyDE || - pushServer == model.MHPNSGlobal || - pushServer == model.MHPNSUS || - pushServer == model.MHPNSEU || - pushServer == model.MHPNSAP - if license := a.Srv().License(); isMHPNSServer && (license == nil || !*license.Features.MHPNS) { + if model.IsMHPNSEndpoint(pushServer) && !a.Srv().License().HasMHPNS() { a.Log().LogM(mlog.MlvlNotificationWarn, "Push notifications are disabled - license missing", mlog.String("status", model.NotificationStatusNotSent), mlog.String("reason", "push_disabled_license"), diff --git a/server/channels/app/push_notification_server.go b/server/channels/app/push_notification_server.go new file mode 100644 index 00000000000..8ef58c80f4a --- /dev/null +++ b/server/channels/app/push_notification_server.go @@ -0,0 +1,79 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/mlog" + "github.com/mattermost/mattermost/server/public/shared/request" +) + +// syncPushNotificationServerWithLicense switches EmailSettings.PushNotificationServer to the +// hosted push notification service (MHPNS) endpoint when the license grants MHPNS access, and +// back to the test (TPNS) endpoint when it no longer does. It runs on license changes, on +// server start, and when this node becomes the cluster leader. +// +// The contract: promote only from exact TPNS to the Global endpoint; on entitlement loss, +// revert any Mattermost-hosted production endpoint (global, regional, or legacy) to TPNS, so +// a lapsed license never leaves push pointing at an endpoint that refuses to send. Custom +// endpoints and env-managed values are never touched. The sync stays stateless because both +// directions derive entirely from the current config value and the license. +func (s *Server) syncPushNotificationServerWithLicense() { + if !s.IsLeader() { + return + } + + license := s.License() + // Cloud config is centrally managed; never rewrite it here. + if license.IsCloud() { + return + } + + if s.platform.IsConfigReadOnly() { + return + } + + // Respect an environment-variable override on the setting. The config store re-applies env + // overrides on save anyway, so without this guard a save would be futile and only produce + // spurious audit records, logs, and cluster config traffic on every license event. + if emailOverrides, ok := s.platform.GetEnvironmentOverrides()["EmailSettings"].(map[string]any); ok { + if _, overridden := emailOverrides["PushNotificationServer"]; overridden { + return + } + } + + entitled := license.HasMHPNS() + + // Decide and mutate on the same snapshot so a concurrent config write between the + // decision and the save can't be stomped with a stale value. The residual race between + // Clone and Set is inherent to every SaveConfig caller. + cfg := s.platform.Config().Clone() + current := *cfg.EmailSettings.PushNotificationServer + + var target string + switch { + case entitled && current == model.GenericNotificationServer: + target = model.MHPNSGlobal + case !entitled && model.IsMHPNSEndpoint(current): + target = model.GenericNotificationServer + default: + return + } + + cfg.EmailSettings.PushNotificationServer = model.NewPointer(target) + if _, _, appErr := s.platform.SaveConfig(cfg, true); appErr != nil { + mlog.Warn("Failed to switch push notification server for license entitlement", + mlog.String("old", current), mlog.String("new", target), mlog.Err(appErr)) + return + } + mlog.Info("Automatically switched push notification server based on license entitlement", + mlog.String("old", current), mlog.String("new", target)) + + rctx := request.EmptyContext(s.Log()) + appInstance := New(ServerConnector(s.Channels())) + rec := appInstance.MakeAuditRecord(rctx, model.AuditEventAutoSelectPushNotificationServer, model.AuditStatusSuccess) + model.AddEventParameterToAuditRec(rec, "old_push_notification_server", current) + model.AddEventParameterToAuditRec(rec, "new_push_notification_server", target) + appInstance.LogAuditRec(rctx, rec, nil) +} diff --git a/server/channels/app/push_notification_server_test.go b/server/channels/app/push_notification_server_test.go new file mode 100644 index 00000000000..357d5430576 --- /dev/null +++ b/server/channels/app/push_notification_server_test.go @@ -0,0 +1,192 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + + "github.com/mattermost/mattermost/server/public/model" + emailmocks "github.com/mattermost/mattermost/server/v8/channels/app/email/mocks" + clustermocks "github.com/mattermost/mattermost/server/v8/einterfaces/mocks" +) + +func TestSyncPushNotificationServerWithLicense(t *testing.T) { + // Not parallel: subtests mutate the license, shared config, and environment. + th := Setup(t) + + licenseWithMHPNS := model.NewTestLicense("mhpns") + licenseWithoutMHPNS := model.NewTestLicense() + licenseWithoutMHPNS.Features.MHPNS = model.NewPointer(false) + + tests := []struct { + name string + license *model.License + initialServer string + expectedServer string + }{ + { + name: "entitled license switches TPNS to Global", + license: licenseWithMHPNS, + initialServer: model.GenericNotificationServer, + expectedServer: model.MHPNSGlobal, + }, + { + name: "entitlement removed reverts Global to TPNS", + license: licenseWithoutMHPNS, + initialServer: model.MHPNSGlobal, + expectedServer: model.GenericNotificationServer, + }, + { + name: "license removed reverts Global to TPNS", + license: nil, + initialServer: model.MHPNSGlobal, + expectedServer: model.GenericNotificationServer, + }, + { + name: "entitled license leaves custom endpoint untouched", + license: licenseWithMHPNS, + initialServer: "https://push.example.com", + expectedServer: "https://push.example.com", + }, + { + name: "entitled license leaves regional endpoint untouched", + license: licenseWithMHPNS, + initialServer: model.MHPNSEU, + expectedServer: model.MHPNSEU, + }, + { + name: "entitled license leaves Global untouched", + license: licenseWithMHPNS, + initialServer: model.MHPNSGlobal, + expectedServer: model.MHPNSGlobal, + }, + { + name: "unentitled license reverts regional endpoint (MHPNSUS) to TPNS", + license: licenseWithoutMHPNS, + initialServer: model.MHPNSUS, + expectedServer: model.GenericNotificationServer, + }, + { + name: "unentitled license reverts legacy endpoint (MHPNSLegacyDE) to TPNS", + license: licenseWithoutMHPNS, + initialServer: model.MHPNSLegacyDE, + expectedServer: model.GenericNotificationServer, + }, + { + name: "unentitled license leaves TPNS untouched", + license: licenseWithoutMHPNS, + initialServer: model.GenericNotificationServer, + expectedServer: model.GenericNotificationServer, + }, + { + name: "unentitled license leaves custom endpoint untouched", + license: licenseWithoutMHPNS, + initialServer: "https://push.example.com", + expectedServer: "https://push.example.com", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + th.App.Srv().SetLicense(nil) + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.EmailSettings.PushNotificationServer = tc.initialServer + *cfg.EmailSettings.SendPushNotifications = true + }) + + // Setting the license fires the listener registered in NewServer, + // which runs syncPushNotificationServerWithLicense. + th.App.Srv().SetLicense(tc.license) + + cfg := th.App.Config() + assert.Equal(t, tc.expectedServer, *cfg.EmailSettings.PushNotificationServer) + assert.True(t, *cfg.EmailSettings.SendPushNotifications, "SendPushNotifications must never be modified") + }) + } + + t.Run("direct call switches TPNS to Global on the startup path", func(t *testing.T) { + th.App.Srv().SetLicense(licenseWithMHPNS) + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.EmailSettings.PushNotificationServer = model.GenericNotificationServer + }) + + th.Server.syncPushNotificationServerWithLicense() + + assert.Equal(t, model.MHPNSGlobal, *th.App.Config().EmailSettings.PushNotificationServer) + }) + + t.Run("license with nil MHPNS feature is unentitled and reverts Global to TPNS", func(t *testing.T) { + license := model.NewTestLicense() + th.App.Srv().SetLicense(license) + // SetLicense normalizes feature defaults, back-filling any nil pointer, so a license + // with a nil MHPNS can only reach the sync through the direct path. Clear the field + // on the stored license to prove the entitlement check is nil-safe and treats the + // license as unentitled. Restore it afterwards: license logging during teardown + // dereferences every feature pointer via Features.ToMap. + license.Features.MHPNS = nil + t.Cleanup(func() { license.Features.MHPNS = model.NewPointer(false) }) + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.EmailSettings.PushNotificationServer = model.MHPNSGlobal + }) + + th.Server.syncPushNotificationServerWithLicense() + + assert.Equal(t, model.GenericNotificationServer, *th.App.Config().EmailSettings.PushNotificationServer) + }) + + t.Run("environment override leaves setting untouched", func(t *testing.T) { + t.Setenv("MM_EMAILSETTINGS_PUSHNOTIFICATIONSERVER", model.GenericNotificationServer) + + // The config value alone can't prove the env-override guard fired: a save of this + // config would be a no-op anyway, because the store re-applies env overrides and + // skips config listeners when the effective config is unchanged. The one side + // effect a futile save cannot avoid is cluster propagation — SaveConfig calls + // ConfigChanged on the cluster interface unconditionally — so probe that to prove + // the guard returned before saving. + clusterMock := &clustermocks.ClusterInterface{} + clusterMock.On("IsLeader").Return(true).Maybe() + clusterMock.On("GetClusterId").Return("").Maybe() + clusterMock.On("SendClusterMessage", mock.Anything).Return().Maybe() + clusterMock.On("RegisterClusterMessageHandler", mock.Anything, mock.Anything).Return().Maybe() + clusterMock.On("StopInterNodeCommunication").Return().Maybe() + clusterMock.On("Shutdown").Return().Maybe() + clusterMock.On("ConfigChanged", mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe() + + // The subtest needs its own harness so the mock is installed before the platform + // starts; swapping the cluster interface mid-test races with platform goroutines + // that read it. Setup also isolates the env var set above. + envTh := SetupWithClusterMock(t, clusterMock) + + envTh.App.Srv().SetLicense(licenseWithMHPNS) + envTh.App.UpdateConfig(func(cfg *model.Config) { + *cfg.EmailSettings.PushNotificationServer = model.GenericNotificationServer + }) + + envTh.Server.syncPushNotificationServerWithLicense() + + clusterMock.AssertNotCalled(t, "ConfigChanged", mock.Anything, mock.Anything, mock.Anything) + assert.Equal(t, model.GenericNotificationServer, *envTh.App.Config().EmailSettings.PushNotificationServer) + }) + + t.Run("reverting hosted endpoint does not re-init email batching", func(t *testing.T) { + originalEmailService := th.App.Srv().EmailService + t.Cleanup(func() { + th.App.Srv().EmailService = originalEmailService + }) + + emailServiceMock := emailmocks.ServiceInterface{} + th.App.Srv().EmailService = &emailServiceMock + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.EmailSettings.PushNotificationServer = model.MHPNSGlobal + }) + th.App.Srv().SetLicense(nil) + + emailServiceMock.AssertNotCalled(t, "InitEmailBatching") + assert.Equal(t, model.GenericNotificationServer, *th.App.Config().EmailSettings.PushNotificationServer) + }) +} diff --git a/server/channels/app/server.go b/server/channels/app/server.go index defab167a4d..5f35380c73f 100644 --- a/server/channels/app/server.go +++ b/server/channels/app/server.go @@ -128,6 +128,9 @@ type Server struct { clusterLeaderListenerId string loggerLicenseListenerId string + pushNotificationServerLicenseListenerId string + pushNotificationServerClusterLeaderListenerId string + platform *platform.PlatformService platformOptions []platform.Option telemetryService *telemetry.TelemetryService @@ -506,9 +509,11 @@ func NewServer(options ...Option) (*Server, error) { } } - // Start email batching because it's not like the other jobs - s.platform.AddConfigListener(func(_, _ *model.Config) { - s.EmailService.InitEmailBatching() + // Re-init email batching only when its enable flag or interval changes. + s.platform.AddConfigListener(func(oldCfg, newCfg *model.Config) { + if emailBatchingSettingChanged(oldCfg, newCfg) { + s.EmailService.InitEmailBatching() + } }) pwd, _ := os.Getwd() @@ -540,6 +545,15 @@ func NewServer(options ...Option) (*Server, error) { s.platform.EnableLoggingMetrics() }) + // Keep the push notification server in sync with the license's HPNS entitlement, and let a + // newly-elected cluster leader repair any transition missed while another node was leader. + s.pushNotificationServerLicenseListenerId = s.AddLicenseListener(func(oldLicense, newLicense *model.License) { + s.syncPushNotificationServerWithLicense() + }) + s.pushNotificationServerClusterLeaderListenerId = s.AddClusterLeaderChangedListener(func() { + s.syncPushNotificationServerWithLicense() + }) + // if enabled - perform initial product notices fetch if *s.platform.Config().AnnouncementSettings.AdminNoticesEnabled || *s.platform.Config().AnnouncementSettings.UserNoticesEnabled { s.platform.Go(func() { @@ -767,6 +781,8 @@ func (s *Server) Shutdown() { s.RemoveLicenseListener(s.loggerLicenseListenerId) s.RemoveClusterLeaderChangedListener(s.clusterLeaderListenerId) + s.RemoveLicenseListener(s.pushNotificationServerLicenseListenerId) + s.RemoveClusterLeaderChangedListener(s.pushNotificationServerClusterLeaderListenerId) var err error s.serviceMux.RLock() @@ -1008,6 +1024,8 @@ func (s *Server) Start() error { } } + s.syncPushNotificationServerWithLicense() + s.checkPushNotificationServerURL() if err = s.platform.ReloadConfig(); err != nil { @@ -2045,3 +2063,13 @@ func (s *Server) Platform() *platform.PlatformService { func (s *Server) Log() *mlog.Logger { return s.platform.Logger() } + +func emailBatchingSettingChanged(oldCfg, newCfg *model.Config) bool { + if oldCfg == nil || newCfg == nil { + return true + } + return model.SafeDereference(oldCfg.EmailSettings.EnableEmailBatching) != + model.SafeDereference(newCfg.EmailSettings.EnableEmailBatching) || + model.SafeDereference(oldCfg.EmailSettings.EmailBatchingInterval) != + model.SafeDereference(newCfg.EmailSettings.EmailBatchingInterval) +} diff --git a/server/channels/app/server_test.go b/server/channels/app/server_test.go index ddc3f4909a0..310252d72f9 100644 --- a/server/channels/app/server_test.go +++ b/server/channels/app/server_test.go @@ -566,3 +566,50 @@ func TestOriginChecker(t *testing.T) { require.Equalf(t, tc.Pass, res, "Test case (%d)", i) } } + +func TestEmailBatchingSettingChanged(t *testing.T) { + t.Parallel() + + cfg := func(enabled bool, interval int) *model.Config { + c := &model.Config{} + c.EmailSettings.EnableEmailBatching = model.NewPointer(enabled) + c.EmailSettings.EmailBatchingInterval = model.NewPointer(interval) + return c + } + + tests := []struct { + name string + oldCfg *model.Config + newCfg *model.Config + expected bool + }{ + {name: "nil old config", oldCfg: nil, newCfg: cfg(true, 30), expected: true}, + {name: "nil new config", oldCfg: cfg(true, 30), newCfg: nil, expected: true}, + {name: "unchanged disabled", oldCfg: cfg(false, 30), newCfg: cfg(false, 30), expected: false}, + {name: "unchanged enabled", oldCfg: cfg(true, 30), newCfg: cfg(true, 30), expected: false}, + {name: "enabled", oldCfg: cfg(false, 30), newCfg: cfg(true, 30), expected: true}, + {name: "disabled", oldCfg: cfg(true, 30), newCfg: cfg(false, 30), expected: true}, + {name: "interval changed", oldCfg: cfg(true, 30), newCfg: cfg(true, 300), expected: true}, + { + name: "push notification server change is ignored", + oldCfg: func() *model.Config { + c := cfg(false, 30) + c.EmailSettings.PushNotificationServer = model.NewPointer(model.MHPNSGlobal) + return c + }(), + newCfg: func() *model.Config { + c := cfg(false, 30) + c.EmailSettings.PushNotificationServer = model.NewPointer(model.GenericNotificationServer) + return c + }(), + expected: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.expected, emailBatchingSettingChanged(tc.oldCfg, tc.newCfg)) + }) + } +} diff --git a/server/channels/app/team_test.go b/server/channels/app/team_test.go index 4df60b6ceda..5e10ee7750c 100644 --- a/server/channels/app/team_test.go +++ b/server/channels/app/team_test.go @@ -2219,7 +2219,9 @@ func TestInviteNewUsersToTeamGracefully(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic(t) - th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise)) + license := model.NewTestLicenseWithFalseDefaults("mhpns") + license.SkuShortName = model.LicenseShortSkuEnterprise + th.App.Srv().SetLicense(license) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableEmailInvitations = true *cfg.TeamSettings.LockProfileFieldsForEmailUsers = model.TeamSettingsLockProfileFieldsNameAndUsername @@ -2368,6 +2370,8 @@ func TestInviteNewUsersToTeamGracefully(t *testing.T) { t.Run("it returns error for deactivated user without sending email", func(t *testing.T) { emailServiceMock := emailmocks.ServiceInterface{} emailServiceMock.On("Stop").Once().Return() + // The teardown license reset saves a config change, whose listener re-inits email batching. + emailServiceMock.On("InitEmailBatching").Return().Maybe() th.App.Srv().EmailService = &emailServiceMock _, appErr := th.App.UpdateActive(th.Context, th.BasicUser2, false) diff --git a/server/public/model/audit_events.go b/server/public/model/audit_events.go index 8d047029c58..ff916bd7d24 100644 --- a/server/public/model/audit_events.go +++ b/server/public/model/audit_events.go @@ -142,15 +142,16 @@ const ( // Configuration const ( - AuditEventConfigReload = "configReload" // reload server configuration - AuditEventGetConfig = "getConfig" // get current server configuration - AuditEventLocalGetClientConfig = "localGetClientConfig" // get client configuration locally - AuditEventLocalGetConfig = "localGetConfig" // get server configuration locally - AuditEventLocalPatchConfig = "localPatchConfig" // update server configuration locally - AuditEventLocalUpdateConfig = "localUpdateConfig" // update server configuration locally - AuditEventMigrateConfig = "migrateConfig" // migrate configs with file values from one store to another - AuditEventPatchConfig = "patchConfig" // update server configuration - AuditEventUpdateConfig = "updateConfig" // update server configuration + AuditEventAutoSelectPushNotificationServer = "autoSelectPushNotificationServer" // automatically switch push notification server based on license entitlement + AuditEventConfigReload = "configReload" // reload server configuration + AuditEventGetConfig = "getConfig" // get current server configuration + AuditEventLocalGetClientConfig = "localGetClientConfig" // get client configuration locally + AuditEventLocalGetConfig = "localGetConfig" // get server configuration locally + AuditEventLocalPatchConfig = "localPatchConfig" // update server configuration locally + AuditEventLocalUpdateConfig = "localUpdateConfig" // update server configuration locally + AuditEventMigrateConfig = "migrateConfig" // migrate configs with file values from one store to another + AuditEventPatchConfig = "patchConfig" // update server configuration + AuditEventUpdateConfig = "updateConfig" // update server configuration ) // Custom Profile Attributes diff --git a/server/public/model/license.go b/server/public/model/license.go index 97f91c84376..40417c40919 100644 --- a/server/public/model/license.go +++ b/server/public/model/license.go @@ -430,6 +430,11 @@ func (l *License) HasSharedChannels() bool { MinimumProfessionalLicense(l) } +// HasMHPNS reports whether the license grants access to the Mattermost hosted push notification service. +func (l *License) HasMHPNS() bool { + return l != nil && l.Features != nil && l.Features.MHPNS != nil && *l.Features.MHPNS +} + // NewTestLicense returns a license that expires in the future and has the given features. func NewTestLicense(features ...string) *License { ret := &License{ diff --git a/server/public/model/license_test.go b/server/public/model/license_test.go index 3a0431c12cb..64b4ba568fc 100644 --- a/server/public/model/license_test.go +++ b/server/public/model/license_test.go @@ -486,6 +486,56 @@ func TestLicenseHasSharedChannels(t *testing.T) { } } +func TestLicenseHasMHPNS(t *testing.T) { + testCases := []struct { + description string + license *License + expectedValue bool + }{ + { + "nil license", + nil, + false, + }, + { + "nil features", + &License{}, + false, + }, + { + "nil MHPNS feature", + &License{ + Features: &Features{}, + }, + false, + }, + { + "MHPNS feature disabled", + &License{ + Features: &Features{ + MHPNS: new(false), + }, + }, + false, + }, + { + "MHPNS feature enabled", + &License{ + Features: &Features{ + MHPNS: new(true), + }, + }, + true, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.description, func(t *testing.T) { + assert.Equal(t, testCase.expectedValue, testCase.license.HasMHPNS()) + }) + } +} + func TestMinimumProfessionalLicense(t *testing.T) { testCases := []struct { description string diff --git a/server/public/model/push_notification.go b/server/public/model/push_notification.go index fc7688c7701..84c6c0b7afd 100644 --- a/server/public/model/push_notification.go +++ b/server/public/model/push_notification.go @@ -4,6 +4,7 @@ package model import ( + "slices" "strings" ) @@ -43,6 +44,19 @@ const ( PushReceived = "Received by device" ) +// IsMHPNSEndpoint reports whether the given push notification server URL is one of the +// Mattermost-hosted (HPNS) production endpoints. +func IsMHPNSEndpoint(url string) bool { + return slices.Contains([]string{ + MHPNSLegacyUS, + MHPNSLegacyDE, + MHPNSGlobal, + MHPNSUS, + MHPNSEU, + MHPNSAP, + }, url) +} + // PushSubType allows for passing additional message type information // to mobile clients in a backwards-compatible way type PushSubType string diff --git a/server/public/model/push_notification_test.go b/server/public/model/push_notification_test.go index 3d59e74017d..9f28c7f91df 100644 --- a/server/public/model/push_notification_test.go +++ b/server/public/model/push_notification_test.go @@ -54,3 +54,29 @@ func TestPushNotificationDeviceId(t *testing.T) { msg.Platform = "" msg.DeviceId = "" } + +func TestIsMHPNSEndpoint(t *testing.T) { + tests := []struct { + name string + url string + expected bool + }{ + {name: "legacy US endpoint", url: MHPNSLegacyUS, expected: true}, + {name: "legacy DE endpoint", url: MHPNSLegacyDE, expected: true}, + {name: "global endpoint", url: MHPNSGlobal, expected: true}, + {name: "US endpoint", url: MHPNSUS, expected: true}, + {name: "EU endpoint", url: MHPNSEU, expected: true}, + {name: "AP endpoint", url: MHPNSAP, expected: true}, + {name: "legacy MHPNS alias", url: MHPNS, expected: true}, + {name: "test endpoint", url: GenericNotificationServer, expected: false}, + {name: "custom endpoint", url: "https://push.example.com", expected: false}, + {name: "empty string", url: "", expected: false}, + {name: "case variant", url: "https://GLOBAL.push.mattermost.com", expected: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.expected, IsMHPNSEndpoint(tc.url)) + }) + } +}