[MM-71060] Health Dashboard PR23: rules for notifications and messaging (#38912)

* [MM-71060] Add the ConfigInt64 health snapshot accessor

TeamSettings.MaxNotificationsPerChannel is the first rule input stored as
*int64, so rules need an int64 counterpart to ConfigInt.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

* [MM-71060] Add notification and messaging health rules

Adds PUSH_ID_ONLY, LINK_PREVIEWS_DISABLED, MAX_NOTIFICATIONS_PER_CHANNEL,
TYPING_MESSAGES_AT_SCALE and SMTP_UNREACHABLE to the notifications area,
with their English text.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

* [MM-71060] Update packet and parity expectations for the messaging rules

The packet fixtures gain the default values of the settings the new rules
read, so offline evaluation resolves them instead of reporting unknown.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

* [MM-71060] Scope the push round-trip test to the push server codes

PUSH_ID_ONLY fires under the test server's default push contents.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* [MM-71060] Use shared unknown reasons and keep parity off SMTP

TYPING_MESSAGES_AT_SCALE and SMTP_UNREACHABLE now report the shared
ReasonStatsUnavailable and ReasonDiagnosticsUnavailable instead of
their own ids. The live/offline parity test turns email notifications
off so the two collections never probe SMTP separately.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

* [MM-71060] Simplify the SMTP rule guard and push test filtering

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* [MM-71060] Share one generic body across the config accessors

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* [MM-71060] Check the SMTP leader explicitly and assert value checks

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* [MM-71060] Evaluate the push contents and link preview rules on Cloud

Cloud admins can change these settings, unlike the cloud_restrictable
ones or the pages RestrictSystemAdmin hides.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* [MM-71060] Note that configValue can become a generic method on Go 1.27

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* [MM-71060] Only flag full push contents when the license offers id_loaded

full is the server default, and without the ID-only push notifications
feature the server falls back to generic, so the finding is only
actionable when the license includes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop LINK_PREVIEWS_DISABLED rule

It only reports a setting an admin deliberately turned off, so it adds noise without a useful signal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop TYPING_MESSAGES_AT_SCALE rule

Typing events are already bounded by MaxNotificationsPerChannel, the
client throttle, and server-side delivery only to viewers of the channel,
so total account count is a poor proxy for typing load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Clarify push and channel-mention health rule text

PUSH_ID_ONLY now states the device reachability cost of id_loaded and
when to mute. MAX_NOTIFICATIONS_PER_CHANNEL now matches its 5000
threshold and states that lowering the limit disables channel-wide
mentions in larger channels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* [MM-71060] Probe SMTP for invitations and verification, fix mention limit wording

Invitation and verification emails go through SMTP even when notification
emails are off, so the Support Packet probe and SMTP_UNREACHABLE now run
when any of those is enabled.

Channel-wide mentions are blocked once a channel reaches
MaxNotificationsPerChannel, not only above it; the finding now shows the
configured limit instead of an off-by-one member count.

* [MM-71060] Set the SMTP-gating settings in the mmctl test packet

The SMTP rule now reads RequireEmailVerification and EnableEmailInvitations, so a packet missing them reports the rule as not evaluated.

* [MM-71060] Probe SMTP when password sign-in is enabled

Local password users can request a reset email even with notification,
verification and invitation emails off, so SMTP_UNREACHABLE and the
Support Packet probe now also run when email or username sign-in is on.

* [MM-71060] Reduce merge conflicts in health rule test fixtures

Expected finding lists are sorted by code and the mmctl test packet
config is built from defaults, so parallel rule PRs stop editing the
same lines.

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
authored and GitHub committed 2026-10-19 10:35:21 +02:00
1 parent 353f76fe30
commit 08cf1105ab
19 files changed
+678 -38

No files matched your search

@@ -468,7 +468,7 @@ func TestHealthCheckPushURLRoundTrip(t *testing.T) {
byCode := map[string]*model.HealthFinding{}
for _, f := range list.Findings {
if strings.HasPrefix(f.Code, "PUSH_") {
if f.Subject == "EmailSettings.PushNotificationServer" {
byCode[f.Code] = f
}
}
@@ -211,8 +211,15 @@ func TestHealthSnapshotLiveOfflineParity(t *testing.T) {
cfg.ServiceSettings.SiteURL = model.NewPointer("http://chat.example.com")
cfg.EmailSettings.SendPushNotifications = model.NewPointer(true)
cfg.EmailSettings.PushNotificationServer = model.NewPointer("http://push.example.com")
// The live and packet collections each probe SMTP and could disagree.
cfg.EmailSettings.SendEmailNotifications = model.NewPointer(false)
cfg.EmailSettings.EnableSignInWithEmail = model.NewPointer(false)
cfg.EmailSettings.EnableSignInWithUsername = model.NewPointer(false)
cfg.SqlSettings.QueryTimeout = model.NewPointer(60)
})
license := model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise)
license.Customer.Id = model.NewId()
th.App.Srv().SetLicense(license)
// Without it the permissions collector fails and the packet carries a warning.txt.
err := th.App.Srv().Store().System().Save(&model.System{Name: model.MigrationKeyAdvancedPermissionsPhase2, Value: "true"})
@@ -256,7 +263,12 @@ func TestHealthSnapshotLiveOfflineParity(t *testing.T) {
firing[evaluation.Code] = true
}
}
assert.Equal(t, map[string]bool{"PUSH_BAD_SCHEME": true, "SITE_URL_HTTP": true, "DB_QUERY_TIMEOUT_HIGH": true}, firing)
assert.Equal(t, map[string]bool{
"DB_QUERY_TIMEOUT_HIGH": true,
"PUSH_BAD_SCHEME": true,
"PUSH_ID_ONLY": true,
"SITE_URL_HTTP": true,
}, firing)
}
func TestClusterNodes(t *testing.T) {
@@ -374,7 +374,12 @@ func (ps *PlatformService) GetSupportPacketDiagnostics(rctx request.CTX) (*model
}
/* Email Notifications */
if model.SafeDereference(ps.Config().EmailSettings.SendEmailNotifications) {
// Invitations, verification and password reset emails go through SMTP even with notification emails off.
if model.SafeDereference(ps.Config().EmailSettings.SendEmailNotifications) ||
model.SafeDereference(ps.Config().EmailSettings.RequireEmailVerification) ||
model.SafeDereference(ps.Config().ServiceSettings.EnableEmailInvitations) ||
model.SafeDereference(ps.Config().EmailSettings.EnableSignInWithEmail) ||
model.SafeDereference(ps.Config().EmailSettings.EnableSignInWithUsername) {
emailSettings := ps.Config().EmailSettings
hostname := utils.GetHostnameFromSiteURL(model.SafeDereference(ps.Config().ServiceSettings.SiteURL))
mailCfg := &mail.SMTPConfig{
@@ -931,13 +931,25 @@ func TestGetSupportPacketDiagnostics(t *testing.T) {
assert.NotEmpty(t, packet.Notifications.Push.Error)
})
t.Run("email notifications disabled", func(t *testing.T) {
t.Run("SMTP unused", func(t *testing.T) {
requireVerification := *th.Service.Config().EmailSettings.RequireEmailVerification
emailInvitations := *th.Service.Config().ServiceSettings.EnableEmailInvitations
signInWithEmail := *th.Service.Config().EmailSettings.EnableSignInWithEmail
signInWithUsername := *th.Service.Config().EmailSettings.EnableSignInWithUsername
th.Service.UpdateConfig(func(cfg *model.Config) {
cfg.EmailSettings.SendEmailNotifications = new(false)
cfg.EmailSettings.RequireEmailVerification = new(false)
cfg.ServiceSettings.EnableEmailInvitations = new(false)
cfg.EmailSettings.EnableSignInWithEmail = new(false)
cfg.EmailSettings.EnableSignInWithUsername = new(false)
})
t.Cleanup(func() {
th.Service.UpdateConfig(func(cfg *model.Config) {
cfg.EmailSettings.SendEmailNotifications = new(true)
cfg.EmailSettings.RequireEmailVerification = new(requireVerification)
cfg.ServiceSettings.EnableEmailInvitations = new(emailInvitations)
cfg.EmailSettings.EnableSignInWithEmail = new(signInWithEmail)
cfg.EmailSettings.EnableSignInWithUsername = new(signInWithUsername)
})
})
@@ -1031,6 +1043,31 @@ func TestGetSupportPacketDiagnostics(t *testing.T) {
assert.NotEmpty(t, packet.Notifications.Email.Error)
})
t.Run("email invitations probe SMTP with notification emails off", func(t *testing.T) {
emailInvitations := *th.Service.Config().ServiceSettings.EnableEmailInvitations
th.Service.UpdateConfig(func(cfg *model.Config) {
cfg.EmailSettings.SendEmailNotifications = new(false)
cfg.ServiceSettings.EnableEmailInvitations = new(true)
cfg.EmailSettings.SMTPServer = new("localhost")
cfg.EmailSettings.SMTPPort = new("1")
cfg.EmailSettings.SMTPServerTimeout = new(1)
})
t.Cleanup(func() {
th.Service.UpdateConfig(func(cfg *model.Config) {
cfg.EmailSettings.SendEmailNotifications = new(true)
cfg.ServiceSettings.EnableEmailInvitations = new(emailInvitations)
cfg.EmailSettings.SMTPServer = model.NewPointer(model.EmailSMTPDefaultServer)
cfg.EmailSettings.SMTPPort = model.NewPointer(model.EmailSMTPDefaultPort)
cfg.EmailSettings.SMTPServerTimeout = new(10)
})
})
packet := getDiagnostics(t)
assert.Equal(t, model.StatusFail, packet.Notifications.Email.Status)
assert.NotEmpty(t, packet.Notifications.Email.Error)
})
t.Run("maps connection pool diagnostics for master and replica", func(t *testing.T) {
originalStore := th.Service.Store
customStore := &fixedDBStatsStore{
+36 -11
View File
@@ -6,6 +6,7 @@ package commands
import (
"archive/zip"
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
@@ -327,20 +328,44 @@ const healthTestPacketGeneratedAt = 1790330400000
func healthTestPacketFiles() map[string]string {
return map[string]string{
model.PacketMetadataFileName: "version: 1\ntype: support-packet\ngenerated_at: 1790330400000\nserver_version: 11.0.4\n",
model.SupportPacketDiagnosticsFileName: "version: 2\nserver:\n hostname: mm.example.com\n version: 11.0.4\ndatabase:\n type: postgres\n version: \"15.8\"\n master_connections_in_use: 1\nelastic:\n status: disabled\n",
model.SupportPacketStatsFileName: "registered_users: 100\nposts: 1000\n",
model.SupportPacketConfigFileName: `{
"ServiceSettings": {"SiteURL": "https://chat.example.com"},
"SqlSettings": {"DataSource": "postgres://****:****@db.example.com:5432/mattermost", "MaxIdleConns": 50, "MaxOpenConns": 100, "Trace": false, "QueryTimeout": 30, "DisableDatabaseSearch": false},
"ClusterSettings": {"Enable": false},
"ElasticsearchSettings": {"EnableIndexing": false, "EnableSearching": false},
"EmailSettings": {"SendPushNotifications": true, "PushNotificationServer": "http://push.example.com"},
"RateLimitSettings": {"Enable": true, "MemoryStoreSize": 10000}
}`,
model.PacketMetadataFileName: "version: 1\ntype: support-packet\ngenerated_at: 1790330400000\nserver_version: 11.0.4\n",
model.SupportPacketDiagnosticsFileName: `version: 2
server:
hostname: mm.example.com
version: 11.0.4
database:
type: postgres
version: "15.8"
master_connections_in_use: 1
elastic:
status: disabled
notifications:
email:
status: OK
`,
model.SupportPacketStatsFileName: "registered_users: 100\nposts: 1000\n",
model.SupportPacketConfigFileName: healthTestPacketConfig(),
}
}
// healthTestPacketConfig starts from defaults so new rules see a realistic config without editing this fixture.
func healthTestPacketConfig() string {
cfg := &model.Config{}
cfg.SetDefaults()
cfg.ServiceSettings.SiteURL = model.NewPointer("https://chat.example.com")
cfg.EmailSettings.SendPushNotifications = model.NewPointer(true)
cfg.EmailSettings.PushNotificationServer = model.NewPointer("http://push.example.com")
cfg.EmailSettings.PushNotificationContents = model.NewPointer(model.IdLoadedNotification)
cfg.SqlSettings.DataSource = model.NewPointer("postgres://****:****@db.example.com:5432/mattermost")
cfg.RateLimitSettings.Enable = model.NewPointer(true)
b, err := json.Marshal(cfg)
if err != nil {
panic(err)
}
return string(b)
}
func (s *MmctlUnitTestSuite) writeHealthTestPacket(files map[string]string) string {
path := filepath.Join(s.T().TempDir(), "packet.zip")
f, err := os.Create(path)
+45
View File
@@ -14756,6 +14756,21 @@
"translation": "Search engine version is unsupported",
"description": "Health dashboard finding title when the Elasticsearch or OpenSearch cluster runs a major version the server does not support."
},
{
"id": "health.rule.max_notifications_per_channel.message",
"translation": "TeamSettings.MaxNotificationsPerChannel is {{.limit}}, so @channel, @all and @here still work in channels with thousands of members. A single channel-wide mention there fans out thousands of notifications at once, which causes latency spikes.",
"description": "Health dashboard finding detail when the per-channel notification limit is above 5000; {{.limit}} is the configured limit, 'TeamSettings.MaxNotificationsPerChannel' is the config setting path and '@channel', '@all' and '@here' are Mattermost mentions, none should be translated."
},
{
"id": "health.rule.max_notifications_per_channel.remediation",
"translation": "Lower TeamSettings.MaxNotificationsPerChannel to 5000 or less (the default is 1000). @channel, @all and @here are disabled once a channel reaches this many members, so check with the teams that use them in large channels first.",
"description": "Health dashboard fix-it step for a high per-channel notification limit; 'TeamSettings.MaxNotificationsPerChannel' is the config setting path and '@channel', '@all' and '@here' are Mattermost mentions, none should be translated."
},
{
"id": "health.rule.max_notifications_per_channel.title",
"translation": "Channel-wide mentions allowed in very large channels",
"description": "Health dashboard finding title when the per-channel notification limit is above 5000, so @channel, @all and @here work in channels with thousands of members; '@channel', '@all' and '@here' are Mattermost mentions and should not be translated."
},
{
"id": "health.rule.push_bad_scheme.message.http",
"translation": "PushNotificationServer {{.url}} uses http://, so push notifications are sent in cleartext.",
@@ -14791,6 +14806,21 @@
"translation": "Push notifications enabled but PushNotificationServer is blank",
"description": "Health dashboard finding title when push notifications are turned on but the push notification server URL is empty; 'PushNotificationServer' is a config setting name and should not be translated."
},
{
"id": "health.rule.push_id_only.message",
"translation": "PushNotificationContents is set to full, so the message text and sender are included in the push payload, which travels through the push proxy and the Apple and Google push services.",
"description": "Health dashboard finding detail when push notifications carry the full message text; 'PushNotificationContents' is a config setting name and 'full' is its value, neither should be translated."
},
{
"id": "health.rule.push_id_only.remediation",
"translation": "For maximum push privacy, set EmailSettings.PushNotificationContents to id_loaded. Only a message ID is sent, and the mobile app fetches the message content from the server. Mobile devices must then be able to reach the server when a notification arrives, so on servers only reachable through a VPN or internal network, notifications can't show message content. If full is a deliberate choice, mute this finding.",
"description": "Health dashboard fix-it step for push notifications that carry the full message text; 'EmailSettings.PushNotificationContents' is the config setting path and 'id_loaded' is its value, neither should be translated."
},
{
"id": "health.rule.push_id_only.title",
"translation": "Push notifications include the full message text",
"description": "Health dashboard finding title when push notification payloads contain the message text and sender."
},
{
"id": "health.rule.push_test_proxy.message",
"translation": "push-test.mattermost.com is the Mattermost test push proxy. It has no SLA, limited throughput, and is not intended for production use. Mobile push delivery will be unreliable for anything beyond small-scale testing.",
@@ -14911,6 +14941,21 @@
"translation": "SiteURL uses http:// (not https://)",
"description": "Health dashboard finding title when the Site URL starts with http:// instead of https://; 'SiteURL' is a config setting name and should not be translated."
},
{
"id": "health.rule.smtp_unreachable.message",
"translation": "The connection test to the SMTP server failed: {{.error}}",
"description": "Health dashboard finding detail when the server could not connect to its SMTP server; {{.error}} is the error returned by the connection test and should not be translated."
},
{
"id": "health.rule.smtp_unreachable.remediation",
"translation": "Email notifications, invitations and password reset emails may not be delivered. Check the SMTP server, port, connection security and credentials in the System Console, confirm the Mattermost server can reach the SMTP host, then use Test Connection to verify.",
"description": "Health dashboard fix-it step when the SMTP connection test failed; 'Test Connection' is the name of the button on the SMTP page of the System Console."
},
{
"id": "health.rule.smtp_unreachable.title",
"translation": "SMTP server connection test failed",
"description": "Health dashboard finding title when the server could not connect to its SMTP server while email notifications are enabled."
},
{
"id": "health.rule.version.unknown.latest",
"translation": "Latest release unknown: it could not be fetched, or the packet does not record it.",
+17 -19
View File
@@ -19,39 +19,37 @@ var collectedJobTypes = []string{
}
func (s *Snapshot) ConfigString(get func(*model.Config) *string) (string, bool) {
if get == nil || !s.Has(model.SectionConfig) || s.Config == nil || s.Config.Config == nil {
value, ok := configValue(s, get)
if !ok || value == model.FakeSetting {
return "", false
}
value := get(s.Config.Config)
if value == nil || *value == model.FakeSetting {
return "", false
}
return *value, true
return value, true
}
func (s *Snapshot) ConfigBool(get func(*model.Config) *bool) (bool, bool) {
if get == nil || !s.Has(model.SectionConfig) || s.Config == nil || s.Config.Config == nil {
return false, false
}
value := get(s.Config.Config)
if value == nil {
return false, false
}
return *value, true
return configValue(s, get)
}
func (s *Snapshot) ConfigInt(get func(*model.Config) *int) (int, bool) {
return configValue(s, get)
}
func (s *Snapshot) ConfigInt64(get func(*model.Config) *int64) (int64, bool) {
return configValue(s, get)
}
// configValue returns ok=false when the config section is absent or the field is nil.
// TODO: make this a generic method on Snapshot once the module targets Go 1.27.
func configValue[T any](s *Snapshot, get func(*model.Config) *T) (T, bool) {
var zero T
if get == nil || !s.Has(model.SectionConfig) || s.Config == nil || s.Config.Config == nil {
return 0, false
return zero, false
}
value := get(s.Config.Config)
if value == nil {
return 0, false
return zero, false
}
return *value, true
@@ -91,6 +91,48 @@ func TestAccessorsReturnFalseWhenSectionAbsent(t *testing.T) {
require.False(t, ok)
}
func TestConfigInt64(t *testing.T) {
t.Parallel()
maxNotifications := func(cfg *model.Config) *int64 { return cfg.TeamSettings.MaxNotificationsPerChannel }
newSnapshot := func(value *int64) *Snapshot {
cfg := &model.Config{}
cfg.TeamSettings.MaxNotificationsPerChannel = value
return &Snapshot{
Config: &model.SupportPacketConfig{Config: cfg},
Sections: map[model.WorkspaceSection]error{model.SectionConfig: nil},
}
}
t.Run("config absent", func(t *testing.T) {
_, ok := (&Snapshot{}).ConfigInt64(maxNotifications)
require.False(t, ok)
})
t.Run("config section failed", func(t *testing.T) {
s := newSnapshot(new(int64(1000)))
s.Sections[model.SectionConfig] = assert.AnError
_, ok := s.ConfigInt64(maxNotifications)
require.False(t, ok)
})
t.Run("nil field", func(t *testing.T) {
_, ok := newSnapshot(nil).ConfigInt64(maxNotifications)
require.False(t, ok)
})
t.Run("nil getter", func(t *testing.T) {
_, ok := newSnapshot(new(int64(1000))).ConfigInt64(nil)
require.False(t, ok)
})
t.Run("set", func(t *testing.T) {
value, ok := newSnapshot(new(int64(1000))).ConfigInt64(maxNotifications)
require.True(t, ok)
assert.Equal(t, int64(1000), value)
})
}
func licensedSnapshot(license model.SupportPacketLicense) *Snapshot {
diag := &model.SupportPacketDiagnostics{License: license}
return NewSnapshot([]*NodeSnapshot{{
@@ -6,6 +6,7 @@ package packet
import (
"archive/zip"
"bytes"
"cmp"
"encoding/json"
"fmt"
"io/fs"
@@ -128,6 +129,7 @@ func TestReadGoldenPackets(t *testing.T) {
{
name: "standalone",
expected: []finding{
{Code: "PUSH_ID_ONLY", State: healthcheck.StateFiring, Subject: "EmailSettings.PushNotificationContents"},
{Code: "PUSH_TEST_PROXY", State: healthcheck.StateFiring, Subject: pushSubject},
{Code: "SITE_URL_HTTP", State: healthcheck.StateFiring, Subject: siteURLSubject},
{Code: "VERSION_EOL", State: healthcheck.StateFiring, Subject: versionSubject},
@@ -136,16 +138,19 @@ func TestReadGoldenPackets(t *testing.T) {
{
name: "ha",
expected: []finding{
{Code: "PUSH_BAD_SCHEME", State: healthcheck.StateFiring, Subject: pushSubject},
{Code: "VERSION_EOL", State: healthcheck.StateFiring, Subject: versionSubject},
{Code: "DB_HA_FAILOVER_STORM_RISK", State: healthcheck.StateFiring, Subject: "SqlSettings.MaxOpenConns"},
{Code: "ES_LIVE_BATCH_SYNC", State: healthcheck.StateFiring, Subject: "ElasticsearchSettings.LiveIndexingBatchSize"},
{Code: "PUSH_BAD_SCHEME", State: healthcheck.StateFiring, Subject: pushSubject},
{Code: "PUSH_ID_ONLY", State: healthcheck.StateFiring, Subject: "EmailSettings.PushNotificationContents"},
{Code: "VERSION_EOL", State: healthcheck.StateFiring, Subject: versionSubject},
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// Sorted so parallel rule PRs insert at different lines instead of all appending.
require.True(t, slices.IsSortedFunc(tc.expected, func(a, b finding) int { return cmp.Compare(a.Code, b.Code) }), "keep expected findings sorted by code")
p := readFiles(t, fixtureFiles(t, tc.name))
assert.Empty(t, p.Warnings)
@@ -301,7 +306,10 @@ func TestReadCloud(t *testing.T) {
for _, f := range evaluate(t, snapshot) {
codes = append(codes, f.Code)
}
assert.ElementsMatch(t, []string{"PUSH_TEST_PROXY"}, codes)
assert.ElementsMatch(t, []string{
"PUSH_ID_ONLY",
"PUSH_TEST_PROXY",
}, codes)
}
func TestReadMissingStats(t *testing.T) {
@@ -3,6 +3,8 @@ license:
company: Example Corp
users: 500
sku_short_name: enterprise
features:
id_loaded: true
server:
# Machine
os: linux
@@ -9,6 +9,9 @@
"EnableIncomingWebhooks": true,
"EnableOutgoingWebhooks": true
},
"TeamSettings": {
"MaxNotificationsPerChannel": 1000
},
"SqlSettings": {
"DriverName": "postgres",
"DataSource": "postgres://****:****@db.example.com:5432/mattermost?connect_timeout=10\u0026sslmode=require",
@@ -3,6 +3,8 @@ license:
company: Example Corp
users: 500
sku_short_name: enterprise
features:
id_loaded: true
server:
# Machine
os: linux
@@ -9,6 +9,9 @@
"EnableIncomingWebhooks": true,
"EnableOutgoingWebhooks": true
},
"TeamSettings": {
"MaxNotificationsPerChannel": 1000
},
"SqlSettings": {
"DriverName": "postgres",
"DataSource": "postgres://****:****@db.example.com:5432/mattermost?connect_timeout=10\u0026sslmode=require",
@@ -3,6 +3,8 @@ license:
company: Example Corp
users: 500
sku_short_name: enterprise
features:
id_loaded: true
server:
# Machine
os: linux
@@ -9,6 +9,9 @@
"EnableIncomingWebhooks": true,
"EnableOutgoingWebhooks": true
},
"TeamSettings": {
"MaxNotificationsPerChannel": 1000
},
"SqlSettings": {
"DriverName": "postgres",
"DataSource": "postgres://****:****@db.example.com:5432/mattermost?connect_timeout=10\u0026sslmode=require",
@@ -9,6 +9,9 @@
"EnableIncomingWebhooks": true,
"EnableOutgoingWebhooks": true
},
"TeamSettings": {
"MaxNotificationsPerChannel": 1000
},
"SqlSettings": {
"DriverName": "postgres",
"DataSource": "postgres://****:****@db.example.com:5432/mattermost?connect_timeout=10\u0026sslmode=require",
@@ -0,0 +1,160 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package notifications
import (
"strconv"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/platform/shared/healthcheck"
)
func init() {
healthcheck.Register(pushIDOnly, maxNotificationsPerChannel, smtpUnreachable)
}
const (
maxNotificationsPerChannelLimit = 5000
notificationsConsolePath = "/admin_console/environment/notifications"
smtpConsolePath = "/admin_console/environment/smtp"
)
var pushIDOnly = healthcheck.Rule{
Code: "PUSH_ID_ONLY",
Area: model.AreaNotifications,
Severity: healthcheck.SeverityInfo,
RuleText: model.RuleText{
TitleID: healthcheck.TranslationId("health.rule.push_id_only.title"),
RemediationID: healthcheck.TranslationId("health.rule.push_id_only.remediation"),
ConsolePath: notificationsConsolePath,
},
Surface: healthcheck.SurfaceProduct,
Volatility: healthcheck.VolatilityStable,
AppliesToCloud: true,
Subject: "EmailSettings.PushNotificationContents",
Eval: evalPushIDOnly,
}
var maxNotificationsPerChannel = healthcheck.Rule{
Code: "MAX_NOTIFICATIONS_PER_CHANNEL",
Area: model.AreaNotifications,
Severity: healthcheck.SeverityInfo,
RuleText: model.RuleText{
TitleID: healthcheck.TranslationId("health.rule.max_notifications_per_channel.title"),
RemediationID: healthcheck.TranslationId("health.rule.max_notifications_per_channel.remediation"),
ConsolePath: pushConsolePath,
},
Surface: healthcheck.SurfaceProduct,
Volatility: healthcheck.VolatilityStable,
Subject: "TeamSettings.MaxNotificationsPerChannel",
Eval: evalMaxNotificationsPerChannel,
}
var smtpUnreachable = healthcheck.Rule{
Code: "SMTP_UNREACHABLE",
Area: model.AreaNotifications,
Severity: healthcheck.SeverityWarning,
RuleText: model.RuleText{
TitleID: healthcheck.TranslationId("health.rule.smtp_unreachable.title"),
RemediationID: healthcheck.TranslationId("health.rule.smtp_unreachable.remediation"),
ConsolePath: smtpConsolePath,
},
Surface: healthcheck.SurfaceProduct,
Volatility: healthcheck.VolatilityProbe,
Subject: "EmailSettings.SMTPServer",
Eval: evalSMTPUnreachable,
}
func evalPushIDOnly(s *healthcheck.Snapshot) []healthcheck.Result {
enabled, ok := s.ConfigBool(func(cfg *model.Config) *bool { return cfg.EmailSettings.SendPushNotifications })
if !ok {
return []healthcheck.Result{healthcheck.Unknown(healthcheck.ReasonConfigUnavailable)}
}
if !enabled {
return []healthcheck.Result{healthcheck.Resolved()}
}
contents, ok := s.ConfigString(func(cfg *model.Config) *string { return cfg.EmailSettings.PushNotificationContents })
if !ok {
return []healthcheck.Result{healthcheck.Unknown(healthcheck.ReasonConfigUnavailable)}
}
if contents != model.FullNotification {
return []healthcheck.Result{healthcheck.Resolved()}
}
// full is the server default, so only flag it when id_loaded is an option for this license.
idLoaded, ok := s.LicenseFeature(func(f *model.Features) *bool { return f.IDLoadedPushNotifications })
switch {
case !ok:
return []healthcheck.Result{healthcheck.Unknown(healthcheck.ReasonLicenseUnavailable)}
case idLoaded:
return []healthcheck.Result{healthcheck.Firing(healthcheck.TranslationId("health.rule.push_id_only.message"))}
default:
return []healthcheck.Result{healthcheck.Resolved()}
}
}
func evalMaxNotificationsPerChannel(s *healthcheck.Snapshot) []healthcheck.Result {
limit, ok := s.ConfigInt64(func(cfg *model.Config) *int64 { return cfg.TeamSettings.MaxNotificationsPerChannel })
switch {
case !ok:
return []healthcheck.Result{healthcheck.Unknown(healthcheck.ReasonConfigUnavailable)}
case limit > maxNotificationsPerChannelLimit:
return []healthcheck.Result{healthcheck.Firing(healthcheck.TranslationId("health.rule.max_notifications_per_channel.message")).WithDetail("limit", strconv.FormatInt(limit, 10)).WithValue(float64(limit))}
default:
return []healthcheck.Result{healthcheck.Resolved().WithValue(float64(limit))}
}
}
// smtpInUse mirrors the gate the Support Packet uses before probing SMTP.
func smtpInUse(s *healthcheck.Snapshot) (inUse, ok bool) {
for _, get := range []func(*model.Config) *bool{
func(cfg *model.Config) *bool { return cfg.EmailSettings.SendEmailNotifications },
func(cfg *model.Config) *bool { return cfg.EmailSettings.RequireEmailVerification },
func(cfg *model.Config) *bool { return cfg.ServiceSettings.EnableEmailInvitations },
func(cfg *model.Config) *bool { return cfg.EmailSettings.EnableSignInWithEmail },
func(cfg *model.Config) *bool { return cfg.EmailSettings.EnableSignInWithUsername },
} {
enabled, ok := s.ConfigBool(get)
if !ok {
return false, false
}
if enabled {
return true, true
}
}
return false, true
}
func evalSMTPUnreachable(s *healthcheck.Snapshot) []healthcheck.Result {
inUse, ok := smtpInUse(s)
if !ok {
return []healthcheck.Result{healthcheck.Unknown(healthcheck.ReasonConfigUnavailable)}
}
if !inUse {
return []healthcheck.Result{healthcheck.Resolved()}
}
notCollected := []healthcheck.Result{healthcheck.Unknown(healthcheck.ReasonDiagnosticsUnavailable)}
leader, ok := s.Leader()
if !ok {
return notCollected
}
diag, ok := leader.Diag()
_, probeErr := leader.SectionErr(model.SectionSMTPProbe)
if !ok || probeErr != nil {
return notCollected
}
email := diag.Notifications.Email
switch email.Status {
case model.StatusOk, model.StatusDisabled:
return []healthcheck.Result{healthcheck.Resolved()}
case model.StatusFail:
return []healthcheck.Result{healthcheck.Firing(healthcheck.TranslationId("health.rule.smtp_unreachable.message")).WithDetail("error", email.Error)}
default:
return notCollected
}
}
@@ -0,0 +1,285 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package notifications
import (
"errors"
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/platform/shared/healthcheck"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func configSnapshot(set func(cfg *model.Config)) *healthcheck.Snapshot {
cfg := &model.Config{}
set(cfg)
return &healthcheck.Snapshot{
Config: &model.SupportPacketConfig{Config: cfg},
Sections: map[model.WorkspaceSection]error{model.SectionConfig: nil},
}
}
type messagingWant struct {
state healthcheck.State
messageID string
value *float64
details map[string]string
}
func assertResult(t *testing.T, rule healthcheck.Rule, snapshot *healthcheck.Snapshot, want messagingWant) {
t.Helper()
results := rule.Eval(snapshot)
require.Len(t, results, 1, rule.Code)
assert.Equal(t, want.state, results[0].State, rule.Code)
assert.Equal(t, want.messageID, results[0].MessageID, rule.Code)
assert.Equal(t, want.value, results[0].Value, rule.Code)
assert.Equal(t, want.details, results[0].Details, rule.Code)
}
var (
resolvedWant = messagingWant{state: healthcheck.StateResolved}
configUnknownWant = messagingWant{state: healthcheck.StateUnknown, messageID: healthcheck.ReasonConfigUnavailable}
)
func withLicenseFeatures(s *healthcheck.Snapshot, features *model.Features) *healthcheck.Snapshot {
diag := &model.SupportPacketDiagnostics{License: model.SupportPacketLicense{Features: features}}
licensed := healthcheck.NewSnapshot([]*healthcheck.NodeSnapshot{{
Hostname: "app-1",
IsLeader: true,
Diagnostics: &model.NodeDiagnostics{Diagnostics: diag},
}})
licensed.Config, licensed.Sections = s.Config, s.Sections
return licensed
}
func TestPushIDOnly(t *testing.T) {
t.Parallel()
fullContents := configSnapshot(func(cfg *model.Config) {
cfg.EmailSettings.SendPushNotifications = new(true)
cfg.EmailSettings.PushNotificationContents = new(model.FullNotification)
})
snapshot := func(enabled bool, contents string) *healthcheck.Snapshot {
return withLicenseFeatures(configSnapshot(func(cfg *model.Config) {
cfg.EmailSettings.SendPushNotifications = new(enabled)
cfg.EmailSettings.PushNotificationContents = new(contents)
}), &model.Features{IDLoadedPushNotifications: new(true)})
}
licenseUnknown := messagingWant{state: healthcheck.StateUnknown, messageID: healthcheck.ReasonLicenseUnavailable}
testCases := []struct {
name string
snapshot *healthcheck.Snapshot
want messagingWant
}{
{name: "config section absent", snapshot: &healthcheck.Snapshot{}, want: configUnknownWant},
{
name: "contents unset",
snapshot: configSnapshot(func(cfg *model.Config) {
cfg.EmailSettings.SendPushNotifications = new(true)
}),
want: configUnknownWant,
},
{name: "push disabled with full contents", snapshot: snapshot(false, model.FullNotification), want: resolvedWant},
{
name: "full",
snapshot: snapshot(true, model.FullNotification),
want: messagingWant{state: healthcheck.StateFiring, messageID: "health.rule.push_id_only.message"},
},
{name: "generic", snapshot: snapshot(true, model.GenericNotification), want: resolvedWant},
{name: "generic no channel", snapshot: snapshot(true, model.GenericNoChannelNotification), want: resolvedWant},
{name: "id loaded", snapshot: snapshot(true, model.IdLoadedNotification), want: resolvedWant},
{name: "full without license", snapshot: fullContents, want: licenseUnknown},
{name: "full with features not in packet", snapshot: withLicenseFeatures(fullContents, nil), want: licenseUnknown},
{
name: "full without id_loaded feature",
snapshot: withLicenseFeatures(fullContents, &model.Features{IDLoadedPushNotifications: new(false)}),
want: resolvedWant,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
assertResult(t, pushIDOnly, tc.snapshot, tc.want)
})
}
}
func TestPushIDOnlyAndPushServerCodesFireTogether(t *testing.T) {
t.Parallel()
snapshot := withLicenseFeatures(configSnapshot(func(cfg *model.Config) {
cfg.EmailSettings.SendPushNotifications = new(true)
cfg.EmailSettings.PushNotificationServer = new("https://push-test.mattermost.com")
cfg.EmailSettings.PushNotificationContents = new(model.FullNotification)
}), &model.Features{IDLoadedPushNotifications: new(true)})
assert.Equal(t, healthcheck.StateFiring, evalPushIDOnly(snapshot)[0].State)
assert.Equal(t, healthcheck.StateFiring, evalPushTestProxy(snapshot)[0].State)
assert.NotEqual(t, pushIDOnly.Subject, pushTestProxy.Subject)
}
func TestMaxNotificationsPerChannel(t *testing.T) {
t.Parallel()
snapshot := func(limit int64) *healthcheck.Snapshot {
return configSnapshot(func(cfg *model.Config) {
cfg.TeamSettings.MaxNotificationsPerChannel = new(limit)
})
}
testCases := []struct {
name string
snapshot *healthcheck.Snapshot
want messagingWant
}{
{name: "config section absent", snapshot: &healthcheck.Snapshot{}, want: configUnknownWant},
{name: "setting unset", snapshot: configSnapshot(func(*model.Config) {}), want: configUnknownWant},
{
name: "default",
snapshot: snapshot(1000),
want: messagingWant{state: healthcheck.StateResolved, value: new(float64(1000))},
},
{
name: "at the limit",
snapshot: snapshot(5000),
want: messagingWant{state: healthcheck.StateResolved, value: new(float64(5000))},
},
{
name: "above the limit",
snapshot: snapshot(5001),
want: messagingWant{
state: healthcheck.StateFiring,
messageID: "health.rule.max_notifications_per_channel.message",
value: new(float64(5001)),
details: map[string]string{"limit": "5001"},
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
assertResult(t, maxNotificationsPerChannel, tc.snapshot, tc.want)
})
}
}
func TestSMTPUnreachable(t *testing.T) {
t.Parallel()
withNodes := func(enabled bool, nodes ...*healthcheck.NodeSnapshot) *healthcheck.Snapshot {
s := healthcheck.NewSnapshot(nodes)
cfg := configSnapshot(func(cfg *model.Config) {
cfg.EmailSettings.SendEmailNotifications = new(enabled)
cfg.EmailSettings.RequireEmailVerification = new(false)
cfg.ServiceSettings.EnableEmailInvitations = new(false)
cfg.EmailSettings.EnableSignInWithEmail = new(false)
cfg.EmailSettings.EnableSignInWithUsername = new(false)
})
s.Config, s.Sections = cfg.Config, cfg.Sections
return s
}
leaderWithEmail := func(status, smtpErr string) *healthcheck.NodeSnapshot {
diag := &model.SupportPacketDiagnostics{}
diag.Notifications.Email.Status = status
diag.Notifications.Email.Error = smtpErr
return &healthcheck.NodeSnapshot{
Hostname: "app-1",
IsLeader: true,
Diagnostics: &model.NodeDiagnostics{Diagnostics: diag},
}
}
notCollected := messagingWant{state: healthcheck.StateUnknown, messageID: healthcheck.ReasonDiagnosticsUnavailable}
probeFailed := leaderWithEmail(model.StatusFail, "dial tcp: connection refused")
probeErrored := leaderWithEmail(model.StatusOk, "")
probeErrored.Diagnostics.Errors = model.SectionErrors{model.SectionSMTPProbe: errors.New("probe timed out")}
follower := leaderWithEmail(model.StatusOk, "")
follower.IsLeader = false
probeFailedWant := messagingWant{
state: healthcheck.StateFiring,
messageID: "health.rule.smtp_unreachable.message",
details: map[string]string{"error": "dial tcp: connection refused"},
}
testCases := []struct {
name string
snapshot *healthcheck.Snapshot
want messagingWant
}{
{name: "config section absent", snapshot: healthcheck.NewSnapshot([]*healthcheck.NodeSnapshot{probeFailed}), want: configUnknownWant},
{name: "email disabled with failed probe", snapshot: withNodes(false, probeFailed), want: resolvedWant},
{name: "email disabled without leader", snapshot: withNodes(false), want: resolvedWant},
{
name: "invitations enabled with notification emails off",
snapshot: func() *healthcheck.Snapshot {
s := withNodes(false, probeFailed)
s.Config.Config.ServiceSettings.EnableEmailInvitations = new(true)
return s
}(),
want: probeFailedWant,
},
{
name: "email verification required with notification emails off",
snapshot: func() *healthcheck.Snapshot {
s := withNodes(false, probeFailed)
s.Config.Config.EmailSettings.RequireEmailVerification = new(true)
return s
}(),
want: probeFailedWant,
},
{
name: "email sign-in enabled with notification emails off",
snapshot: func() *healthcheck.Snapshot {
s := withNodes(false, probeFailed)
s.Config.Config.EmailSettings.EnableSignInWithEmail = new(true)
return s
}(),
want: probeFailedWant,
},
{
name: "username sign-in enabled with notification emails off",
snapshot: func() *healthcheck.Snapshot {
s := withNodes(false, probeFailed)
s.Config.Config.EmailSettings.EnableSignInWithUsername = new(true)
return s
}(),
want: probeFailedWant,
},
{
name: "notification emails off and other settings unset",
snapshot: func() *healthcheck.Snapshot {
s := withNodes(false, probeFailed)
s.Config.Config.ServiceSettings.EnableEmailInvitations = nil
return s
}(),
want: configUnknownWant,
},
{name: "fail", snapshot: withNodes(true, probeFailed), want: probeFailedWant},
{name: "ok", snapshot: withNodes(true, leaderWithEmail(model.StatusOk, "")), want: resolvedWant},
{name: "disabled", snapshot: withNodes(true, leaderWithEmail(model.StatusDisabled, "")), want: resolvedWant},
{name: "empty status", snapshot: withNodes(true, leaderWithEmail("", "")), want: notCollected},
{name: "no nodes", snapshot: withNodes(true), want: notCollected},
{name: "only a follower", snapshot: withNodes(true, follower), want: notCollected},
{
name: "leader without diagnostics",
snapshot: withNodes(true, &healthcheck.NodeSnapshot{Hostname: "app-1", IsLeader: true}),
want: notCollected,
},
{name: "probe section errored", snapshot: withNodes(true, probeErrored), want: notCollected},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
assertResult(t, smtpUnreachable, tc.snapshot, tc.want)
})
}
}
@@ -29,5 +29,10 @@ func TestBuiltinRulesOnCloud(t *testing.T) {
}
}
assert.ElementsMatch(t, []string{"PUSH_EMPTY_URL", "PUSH_BAD_SCHEME", "PUSH_TEST_PROXY"}, cloud)
assert.ElementsMatch(t, []string{
"PUSH_BAD_SCHEME",
"PUSH_EMPTY_URL",
"PUSH_ID_ONLY",
"PUSH_TEST_PROXY",
}, cloud)
}