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 <nick.misasi@mattermost.com>

* test: fix mock-store fallout from push endpoint license listener

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* MM-70071: address review feedback on push endpoint sync

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* ci: retrigger enterprise tests against updated companion branch

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* ci: retrigger flaky artifact build

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* MM-70071: revert any hosted push endpoint to test on entitlement loss

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* MM-70071: add nil-safe License.HasMHPNS entitlement check

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <nick13misasi@gmail.com>

* 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 <nick13misasi@gmail.com>

* 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 <cursoragent@cursor.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
Nick Misasi
2026-08-20 16:11:21 +00:00
committed by GitHub
co-authored by Nick Misasi Cursor Mattermost Build
parent c36f979edd
commit 62056e5a7c
14 changed files with 482 additions and 25 deletions
+6 -1
View File
@@ -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)
+10
View File
@@ -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
+6 -2
View File
@@ -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)
+1 -9
View File
@@ -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"),
@@ -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)
}
@@ -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)
})
}
+31 -3
View File
@@ -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)
}
+47
View File
@@ -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))
})
}
}
+5 -1
View File
@@ -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)
+10 -9
View File
@@ -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
+5
View File
@@ -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{
+50
View File
@@ -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
+14
View File
@@ -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
@@ -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))
})
}
}