fleet/server/datastore/mysql/statistics_test.go
Sharon Katz 85f0638f4f
Add statistic to measure ABM pending hosts (#28226)
Co-authored-by: Lucas Manuel Rodriguez <lucas@fleetdm.com>
2025-04-15 11:30:07 -04:00

432 lines
16 KiB
Go

package mysql
import (
"context"
"database/sql"
"encoding/json"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/contexts/license"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestStatistics(t *testing.T) {
ds := CreateMySQLDS(t)
cases := []struct {
name string
fn func(t *testing.T, ds *Datastore)
}{
{"ShouldSend", testStatisticsShouldSend},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
defer TruncateTables(t, ds)
c.fn(t, ds)
})
}
}
func testStatisticsShouldSend(t *testing.T, ds *Datastore) {
eh := ctxerr.MockHandler{}
// Mock the error handler to always return an error
eh.RetrieveImpl = func(flush bool) ([]*ctxerr.StoredError, error) {
require.False(t, flush)
return []*ctxerr.StoredError{
{Count: 10, Chain: json.RawMessage(`[{"stack": ["a","b","c","d"]}]`)},
}, nil
}
ctxb := context.Background()
ctx := ctxerr.NewContext(ctxb, eh)
fleetConfig := config.FleetConfig{Osquery: config.OsqueryConfig{DetailUpdateInterval: 1 * time.Hour}}
premiumLicense := &fleet.LicenseInfo{Tier: fleet.TierPremium, Organization: "Fleet"}
freeLicense := &fleet.LicenseInfo{Tier: fleet.TierFree}
var builtinLabels int
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &builtinLabels, `SELECT COUNT(*) FROM labels`)
})
// First time running with no hosts
stats, shouldSend, err := ds.ShouldSendStatistics(license.NewContext(ctx, premiumLicense), time.Millisecond, fleetConfig)
require.NoError(t, err)
assert.True(t, shouldSend)
assert.Equal(t, "premium", stats.LicenseTier)
assert.Equal(t, "Fleet", stats.Organization)
assert.Equal(t, 0, stats.NumHostsEnrolled)
assert.Equal(t, 0, stats.NumHostsABMPending)
assert.Equal(t, 0, stats.NumUsers)
assert.Equal(t, 0, stats.NumSoftwareVersions)
assert.Equal(t, 0, stats.NumHostSoftwares)
assert.Equal(t, 0, stats.NumSoftwareTitles)
assert.Equal(t, 0, stats.NumHostSoftwareInstalledPaths)
assert.Equal(t, 0, stats.NumSoftwareCPEs)
assert.Equal(t, 0, stats.NumSoftwareCVEs)
assert.Equal(t, 0, stats.NumTeams)
assert.Equal(t, 0, stats.NumPolicies)
assert.Equal(t, 0, stats.NumQueries)
assert.Equal(t, builtinLabels, stats.NumLabels)
assert.Equal(t, false, stats.SoftwareInventoryEnabled)
assert.Equal(t, true, stats.SystemUsersEnabled)
assert.Equal(t, false, stats.VulnDetectionEnabled)
assert.Equal(t, false, stats.HostsStatusWebHookEnabled)
assert.Equal(t, 0, stats.NumWeeklyActiveUsers)
assert.Equal(t, 0, stats.NumWeeklyPolicyViolationDaysActual)
assert.Equal(t, 0, stats.NumWeeklyPolicyViolationDaysPossible)
assert.Equal(t, `[{"count":10,"loc":["a","b","c"]}]`, string(stats.StoredErrors))
assert.Equal(t, []fleet.HostsCountByOsqueryVersion{}, stats.HostsEnrolledByOsqueryVersion) // should be empty slice instead of nil
assert.Equal(t, []fleet.HostsCountByOrbitVersion{}, stats.HostsEnrolledByOrbitVersion) // should be empty slice instead of nil
assert.Equal(t, false, stats.MDMMacOsEnabled)
assert.Equal(t, false, stats.HostExpiryEnabled)
assert.Equal(t, false, stats.MDMWindowsEnabled)
assert.Equal(t, false, stats.LiveQueryDisabled)
assert.Equal(t, false, stats.AIFeaturesDisabled)
assert.Equal(t, false, stats.MaintenanceWindowsEnabled)
assert.Equal(t, false, stats.MaintenanceWindowsConfigured)
assert.Equal(t, 0, stats.NumHostsFleetDesktopEnabled)
firstIdentifier := stats.AnonymousIdentifier
err = ds.RecordStatisticsSent(ctx)
require.NoError(t, err)
// Create new host for test
h1, err := ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
NodeKey: ptr.String("1"),
UUID: "1",
Hostname: "foo.local",
PrimaryIP: "192.168.1.1",
PrimaryMac: "30-65-EC-6F-C4-58",
OsqueryHostID: ptr.String("M"),
OsqueryVersion: "4.9.0",
})
require.NoError(t, err)
// Create host_orbit_info record for test
require.NoError(
t, ds.SetOrUpdateHostOrbitInfo(
ctx, h1.ID, "1.1.0", sql.NullString{String: "1.1.0", Valid: true}, sql.NullBool{Bool: true, Valid: true},
),
)
// Create two new users for test
u1, err := ds.NewUser(ctx, &fleet.User{
Password: []byte("foobar"),
AdminForcedPasswordReset: false,
Email: "baz@example.com",
SSOEnabled: false,
GlobalRole: ptr.String(fleet.RoleObserver),
})
require.NoError(t, err)
_, err = ds.NewUser(ctx, &fleet.User{
Password: []byte("foobar"),
AdminForcedPasswordReset: false,
Email: "qux@example.com",
SSOEnabled: false,
GlobalRole: ptr.String(fleet.RoleObserver),
})
require.NoError(t, err)
// Create a session for user baz, but not qux (so only 1 is active)
_, err = ds.NewSession(ctx, u1.ID, 8)
require.NoError(t, err)
// Create new team for test
_, err = ds.NewTeam(ctx, &fleet.Team{
Name: "footeam",
Description: "team of foo",
})
require.NoError(t, err)
// Create new global policy for test
_, err = ds.NewGlobalPolicy(ctx, ptr.Uint(1), fleet.PolicyPayload{
Name: "testpolicy",
Query: "select 1;",
Description: "test policy desc",
Resolution: "test policy resolution",
})
require.NoError(t, err)
// Create new label for test
_, err = ds.NewLabel(ctx, &fleet.Label{
Name: "testlabel",
Query: "select 1;",
Platform: "darwin",
Description: "test label description",
})
require.NoError(t, err)
// Create new app cfg for test
cfg, err := ds.NewAppConfig(ctx, &fleet.AppConfig{
OrgInfo: fleet.OrgInfo{
OrgName: "Test",
OrgLogoURL: "localhost:8080/logo.png",
},
})
require.NoError(t, err)
// Initialize policy violation days for test
pvdJSON, err := json.Marshal(PolicyViolationDays{FailingHostCount: 5, TotalHostCount: 10})
require.NoError(t, err)
_, err = ds.writer(ctx).ExecContext(ctx, `
INSERT INTO
aggregated_stats (id, global_stats, type, json_value, created_at, updated_at)
VALUES (?, ?, ?, CAST(? AS JSON), ?, ?)
ON DUPLICATE KEY UPDATE
json_value = VALUES(json_value),
updated_at = VALUES(updated_at)
`, 0, true, aggregatedStatsTypePolicyViolationsDays, pvdJSON, time.Now().Add(-48*time.Hour), time.Now().Add(-7*24*time.Hour))
require.NoError(t, err)
require.NoError(t, err)
cfg.Features.EnableSoftwareInventory = false
cfg.Features.EnableHostUsers = false
cfg.VulnerabilitySettings.DatabasesPath = ""
cfg.WebhookSettings.HostStatusWebhook.Enable = true
cfg.MDM.EnabledAndConfigured = true
cfg.HostExpirySettings.HostExpiryEnabled = true
cfg.MDM.WindowsEnabledAndConfigured = true
cfg.ServerSettings.LiveQueryDisabled = true
err = ds.SaveAppConfig(ctx, cfg)
require.NoError(t, err)
time.Sleep(1100 * time.Millisecond) // ensure the DB timestamp is not in the same second
// Running with 1 host
stats, shouldSend, err = ds.ShouldSendStatistics(license.NewContext(ctx, premiumLicense), time.Millisecond, fleetConfig)
require.NoError(t, err)
assert.True(t, shouldSend)
assert.NotEmpty(t, stats.AnonymousIdentifier)
assert.NotEmpty(t, stats.FleetVersion)
assert.Equal(t, "premium", stats.LicenseTier)
assert.Equal(t, "Fleet", stats.Organization)
assert.Equal(t, 1, stats.NumHostsEnrolled)
assert.Equal(t, 0, stats.NumHostsABMPending)
assert.Equal(t, 2, stats.NumUsers)
assert.Equal(t, 0, stats.NumSoftwareVersions)
assert.Equal(t, 0, stats.NumHostSoftwares)
assert.Equal(t, 0, stats.NumSoftwareTitles)
assert.Equal(t, 0, stats.NumHostSoftwareInstalledPaths)
assert.Equal(t, 0, stats.NumSoftwareCPEs)
assert.Equal(t, 0, stats.NumSoftwareCVEs)
assert.Equal(t, 1, stats.NumTeams)
assert.Equal(t, 1, stats.NumPolicies)
assert.Equal(t, 0, stats.NumQueries)
assert.Equal(t, builtinLabels+1, stats.NumLabels)
assert.Equal(t, false, stats.SoftwareInventoryEnabled)
assert.Equal(t, false, stats.SystemUsersEnabled)
assert.Equal(t, false, stats.VulnDetectionEnabled)
assert.Equal(t, true, stats.HostsStatusWebHookEnabled)
assert.Equal(t, 1, stats.NumWeeklyActiveUsers)
assert.Equal(t, 5, stats.NumWeeklyPolicyViolationDaysActual)
assert.Equal(t, 10, stats.NumWeeklyPolicyViolationDaysPossible)
assert.Equal(t, `[{"count":10,"loc":["a","b","c"]}]`, string(stats.StoredErrors))
assert.Equal(t, []fleet.HostsCountByOsqueryVersion{{OsqueryVersion: "4.9.0", NumHosts: 1}}, stats.HostsEnrolledByOsqueryVersion)
assert.Equal(t, []fleet.HostsCountByOrbitVersion{{OrbitVersion: "1.1.0", NumHosts: 1}}, stats.HostsEnrolledByOrbitVersion)
assert.Equal(t, false, stats.AIFeaturesDisabled)
assert.Equal(t, false, stats.MaintenanceWindowsEnabled)
assert.Equal(t, false, stats.MaintenanceWindowsConfigured)
assert.Equal(t, 1, stats.NumHostsFleetDesktopEnabled)
err = ds.RecordStatisticsSent(ctx)
require.NoError(t, err)
// If we try right away, it shouldn't ask to send
stats, shouldSend, err = ds.ShouldSendStatistics(license.NewContext(ctx, premiumLicense), fleet.StatisticsFrequency, fleetConfig)
require.NoError(t, err)
assert.False(t, shouldSend)
time.Sleep(1100 * time.Millisecond) // ensure the DB timestamp is not in the same second
// create a few more hosts, with platforms and os versions
_, err = ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
NodeKey: ptr.String("2"),
UUID: "2",
Hostname: "foo.local2",
PrimaryIP: "192.168.1.2",
PrimaryMac: "30-65-EC-6F-C4-59",
OsqueryHostID: ptr.String("S"),
Platform: "rhel",
OSVersion: "Fedora 35",
})
require.NoError(t, err)
_, err = ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
NodeKey: ptr.String("3"),
UUID: "3",
Hostname: "foo.local3",
PrimaryIP: "192.168.1.3",
PrimaryMac: "40-65-EC-6F-C4-59",
OsqueryHostID: ptr.String("T"),
Platform: "rhel",
OSVersion: "Fedora 35",
})
require.NoError(t, err)
_, err = ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
NodeKey: ptr.String("4"),
UUID: "4",
Hostname: "foo.local4",
PrimaryIP: "192.168.1.4",
PrimaryMac: "50-65-EC-6F-C4-59",
OsqueryHostID: ptr.String("U"),
Platform: "macos",
OSVersion: "10.11.12",
})
require.NoError(t, err)
_, err = ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
NodeKey: ptr.String("5"),
UUID: "5",
Hostname: "foo.local5",
PrimaryIP: "192.168.1.5",
PrimaryMac: "60-65-EC-6F-C4-59",
OsqueryHostID: ptr.String("V"),
Platform: "rhel",
OSVersion: "Fedora 36",
})
require.NoError(t, err)
// Lower the frequency to trigger an "outdated" sent
stats, shouldSend, err = ds.ShouldSendStatistics(license.NewContext(ctx, premiumLicense), time.Millisecond, fleetConfig)
require.NoError(t, err)
assert.True(t, shouldSend)
assert.Equal(t, firstIdentifier, stats.AnonymousIdentifier)
assert.Equal(t, "premium", stats.LicenseTier)
assert.Equal(t, "Fleet", stats.Organization)
assert.Equal(t, 5, stats.NumHostsEnrolled)
assert.Equal(t, 0, stats.NumHostsABMPending)
assert.Equal(t, 2, stats.NumUsers)
assert.Equal(t, 0, stats.NumQueries)
assert.Equal(t, 0, stats.NumSoftwareVersions)
assert.Equal(t, 0, stats.NumHostSoftwares)
assert.Equal(t, 0, stats.NumSoftwareTitles)
assert.Equal(t, 0, stats.NumHostSoftwareInstalledPaths)
assert.Equal(t, 0, stats.NumSoftwareCPEs)
assert.Equal(t, 0, stats.NumSoftwareCVEs)
assert.Equal(t, 0, stats.NumWeeklyActiveUsers) // no active user since last stats were sent
require.Len(t, stats.HostsEnrolledByOperatingSystem, 3) // empty platform, rhel and macos
assert.Equal(t, 5, stats.NumWeeklyPolicyViolationDaysActual)
require.ElementsMatch(t, []fleet.HostsCountByOSVersion{
{Version: "Fedora 35", NumEnrolled: 2},
{Version: "Fedora 36", NumEnrolled: 1},
}, stats.HostsEnrolledByOperatingSystem["rhel"])
require.ElementsMatch(t, []fleet.HostsCountByOSVersion{
{Version: "10.11.12", NumEnrolled: 1},
}, stats.HostsEnrolledByOperatingSystem["macos"])
require.ElementsMatch(t, []fleet.HostsCountByOSVersion{
{Version: "", NumEnrolled: 1},
}, stats.HostsEnrolledByOperatingSystem[""])
assert.Equal(t, `[{"count":10,"loc":["a","b","c"]}]`, string(stats.StoredErrors))
assert.Equal(t, false, stats.AIFeaturesDisabled)
assert.Equal(t, false, stats.MaintenanceWindowsEnabled)
assert.Equal(t, false, stats.MaintenanceWindowsConfigured)
assert.Equal(t, 1, stats.NumHostsFleetDesktopEnabled)
// Create multiple new sessions for a single user
_, err = ds.NewSession(ctx, u1.ID, 8)
require.NoError(t, err)
_, err = ds.NewSession(ctx, u1.ID, 8)
require.NoError(t, err)
_, err = ds.NewSession(ctx, u1.ID, 8)
require.NoError(t, err)
// CleanupStatistics resets policy violation days
err = ds.CleanupStatistics(ctx)
require.NoError(t, err)
// wait a bit and resend statistics
time.Sleep(1100 * time.Millisecond) // ensure the DB timestamp is not in the same second
stats, shouldSend, err = ds.ShouldSendStatistics(license.NewContext(ctx, premiumLicense), time.Millisecond, fleetConfig)
require.NoError(t, err)
assert.True(t, shouldSend)
assert.Equal(t, stats.AnonymousIdentifier, firstIdentifier)
assert.Equal(t, "premium", stats.LicenseTier)
assert.Equal(t, "Fleet", stats.Organization)
assert.Equal(t, 5, stats.NumHostsEnrolled)
assert.Equal(t, 0, stats.NumHostsABMPending)
assert.Equal(t, 2, stats.NumUsers)
assert.Equal(t, 0, stats.NumQueries)
assert.Equal(t, 0, stats.NumSoftwareVersions)
assert.Equal(t, 0, stats.NumHostSoftwares)
assert.Equal(t, 0, stats.NumSoftwareTitles)
assert.Equal(t, 0, stats.NumHostSoftwareInstalledPaths)
assert.Equal(t, 0, stats.NumSoftwareCPEs)
assert.Equal(t, 0, stats.NumSoftwareCVEs)
assert.Equal(t, 1, stats.NumWeeklyActiveUsers)
assert.Equal(t, 0, stats.NumWeeklyPolicyViolationDaysActual)
assert.Equal(t, 0, stats.NumWeeklyPolicyViolationDaysPossible)
assert.Equal(t, `[{"count":10,"loc":["a","b","c"]}]`, string(stats.StoredErrors))
assert.Equal(t, false, stats.AIFeaturesDisabled)
assert.Equal(t, false, stats.MaintenanceWindowsEnabled)
assert.Equal(t, false, stats.MaintenanceWindowsConfigured)
assert.Equal(t, 1, stats.NumHostsFleetDesktopEnabled)
// Add host to test hosts not responding stats
_, err = ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now().Add(-3 * time.Hour),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
NodeKey: ptr.String("6"),
UUID: "6",
Hostname: "non-responsive.local",
PrimaryIP: "192.168.1.6",
PrimaryMac: "30-65-EC-6F-C4-66",
OsqueryHostID: ptr.String("NR"),
})
require.NoError(t, err)
stats, shouldSend, err = ds.ShouldSendStatistics(license.NewContext(ctx, premiumLicense), time.Millisecond, fleetConfig)
require.NoError(t, err)
assert.True(t, shouldSend)
assert.Equal(t, firstIdentifier, stats.AnonymousIdentifier)
assert.Equal(t, "premium", stats.LicenseTier)
assert.Equal(t, "Fleet", stats.Organization)
assert.Equal(t, 6, stats.NumHostsEnrolled)
assert.Equal(t, 1, stats.NumHostsNotResponding)
// trigger again with a free license, organization should be "unknown"
time.Sleep(1100 * time.Millisecond) // ensure the DB timestamp is not in the same second
stats, shouldSend, err = ds.ShouldSendStatistics(license.NewContext(ctx, freeLicense), time.Millisecond, fleetConfig)
require.NoError(t, err)
assert.True(t, shouldSend)
assert.Equal(t, firstIdentifier, stats.AnonymousIdentifier)
assert.Equal(t, "free", stats.LicenseTier)
assert.Equal(t, "unknown", stats.Organization)
fleetConfig.Vulnerabilities = config.VulnerabilitiesConfig{DatabasesPath: "some/path/vulns"}
stats, shouldSend, err = ds.ShouldSendStatistics(license.NewContext(ctx, freeLicense), time.Millisecond, fleetConfig)
require.NoError(t, err)
assert.True(t, shouldSend)
assert.True(t, stats.VulnDetectionEnabled)
}