mirror of
https://github.com/mattermost/mattermost.git
synced 2026-11-02 20:31:01 -06:00
[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:
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{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+2
@@ -3,6 +3,8 @@ license:
|
||||
company: Example Corp
|
||||
users: 500
|
||||
sku_short_name: enterprise
|
||||
features:
|
||||
id_loaded: true
|
||||
server:
|
||||
# Machine
|
||||
os: linux
|
||||
|
||||
Vendored
+3
@@ -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",
|
||||
|
||||
+2
@@ -3,6 +3,8 @@ license:
|
||||
company: Example Corp
|
||||
users: 500
|
||||
sku_short_name: enterprise
|
||||
features:
|
||||
id_loaded: true
|
||||
server:
|
||||
# Machine
|
||||
os: linux
|
||||
|
||||
Vendored
+3
@@ -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",
|
||||
|
||||
+2
@@ -3,6 +3,8 @@ license:
|
||||
company: Example Corp
|
||||
users: 500
|
||||
sku_short_name: enterprise
|
||||
features:
|
||||
id_loaded: true
|
||||
server:
|
||||
# Machine
|
||||
os: linux
|
||||
|
||||
Vendored
+3
@@ -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
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user