2215 lines
86 KiB
Go
2215 lines
86 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
hostidentity_types "github.com/fleetdm/fleet/v4/ee/pkg/hostidentity/types"
|
|
"github.com/fleetdm/fleet/v4/pkg/optjson"
|
|
activity_api "github.com/fleetdm/fleet/v4/server/activity/api"
|
|
"github.com/fleetdm/fleet/v4/server/config"
|
|
"github.com/fleetdm/fleet/v4/server/contexts/capabilities"
|
|
hostctx "github.com/fleetdm/fleet/v4/server/contexts/host"
|
|
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
|
|
"github.com/fleetdm/fleet/v4/server/datastore/mysql/mysqltest"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
"github.com/fleetdm/fleet/v4/server/mdm"
|
|
"github.com/fleetdm/fleet/v4/server/mock"
|
|
"github.com/fleetdm/fleet/v4/server/platform/mysql/testing_utils"
|
|
"github.com/fleetdm/fleet/v4/server/ptr"
|
|
"github.com/fleetdm/fleet/v4/server/test"
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestGetOrbitConfigLinuxEscrow(t *testing.T) {
|
|
setupEscrowContext := func() (*mock.Store, fleet.Service, context.Context, *fleet.Host, fleet.Team) {
|
|
ds := new(mock.Store)
|
|
license := &fleet.LicenseInfo{Tier: fleet.TierPremium}
|
|
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true})
|
|
os := &fleet.OperatingSystem{
|
|
Platform: "ubuntu",
|
|
Version: "20.04",
|
|
}
|
|
host := &fleet.Host{
|
|
OsqueryHostID: ptr.String("test"),
|
|
ID: 1,
|
|
OSVersion: "Ubuntu 20.04",
|
|
Platform: "ubuntu",
|
|
DiskEncryptionEnabled: ptr.Bool(true),
|
|
}
|
|
|
|
team := fleet.Team{ID: 1}
|
|
teamMDM := fleet.TeamMDM{EnableDiskEncryption: true}
|
|
ds.TeamMDMConfigFunc = func(ctx context.Context, teamID uint) (*fleet.TeamMDM, error) {
|
|
require.Equal(t, team.ID, teamID)
|
|
return &teamMDM, nil
|
|
}
|
|
ds.TeamAgentOptionsFunc = func(ctx context.Context, id uint) (*json.RawMessage, error) {
|
|
return ptr.RawMessage(json.RawMessage(`{}`)), nil
|
|
}
|
|
ds.ListReadyToExecuteScriptsForHostFunc = func(ctx context.Context, hostID uint, onlyShowInternal bool) ([]*fleet.HostScriptResult, error) {
|
|
return nil, nil
|
|
}
|
|
ds.ListReadyToExecuteSoftwareInstallsFunc = func(ctx context.Context, hostID uint) ([]string, error) {
|
|
return nil, nil
|
|
}
|
|
ds.IsHostConnectedToFleetMDMFunc = func(ctx context.Context, host *fleet.Host) (bool, error) {
|
|
return true, nil
|
|
}
|
|
ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) {
|
|
return nil, nil
|
|
}
|
|
ds.IsHostPendingEscrowFunc = func(ctx context.Context, hostID uint) bool {
|
|
return true
|
|
}
|
|
ds.ClearPendingEscrowFunc = func(ctx context.Context, hostID uint) error {
|
|
return nil
|
|
}
|
|
|
|
appCfg := &fleet.AppConfig{MDM: fleet.MDM{EnableDiskEncryption: optjson.SetBool(true)}}
|
|
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
|
return appCfg, nil
|
|
}
|
|
ds.GetHostOperatingSystemFunc = func(ctx context.Context, hostID uint) (*fleet.OperatingSystem, error) {
|
|
return os, nil
|
|
}
|
|
|
|
ds.GetHostAwaitingConfigurationFunc = func(ctx context.Context, hostUUID string) (bool, error) {
|
|
return false, nil
|
|
}
|
|
|
|
ctx = test.HostContext(ctx, host)
|
|
|
|
return ds, svc, ctx, host, team
|
|
}
|
|
|
|
t.Run("don't check for pending escrow if unsupported platform or encryption is not enabled", func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
license := &fleet.LicenseInfo{Tier: fleet.TierPremium}
|
|
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true})
|
|
os := &fleet.OperatingSystem{
|
|
Platform: "rhel",
|
|
Version: "9.0",
|
|
}
|
|
host := &fleet.Host{
|
|
OsqueryHostID: ptr.String("test"),
|
|
ID: 1,
|
|
OSVersion: "Red Hat Enterprise Linux 9.0",
|
|
Platform: "rhel",
|
|
}
|
|
|
|
team := fleet.Team{ID: 1}
|
|
teamMDM := fleet.TeamMDM{EnableDiskEncryption: true}
|
|
ds.TeamMDMConfigFunc = func(ctx context.Context, teamID uint) (*fleet.TeamMDM, error) {
|
|
require.Equal(t, team.ID, teamID)
|
|
return &teamMDM, nil
|
|
}
|
|
ds.TeamAgentOptionsFunc = func(ctx context.Context, id uint) (*json.RawMessage, error) {
|
|
return ptr.RawMessage(json.RawMessage(`{}`)), nil
|
|
}
|
|
ds.ListReadyToExecuteScriptsForHostFunc = func(ctx context.Context, hostID uint, onlyShowInternal bool) ([]*fleet.HostScriptResult, error) {
|
|
return nil, nil
|
|
}
|
|
ds.ListReadyToExecuteSoftwareInstallsFunc = func(ctx context.Context, hostID uint) ([]string, error) {
|
|
return nil, nil
|
|
}
|
|
ds.IsHostConnectedToFleetMDMFunc = func(ctx context.Context, host *fleet.Host) (bool, error) {
|
|
return true, nil
|
|
}
|
|
ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
appCfg := &fleet.AppConfig{MDM: fleet.MDM{EnableDiskEncryption: optjson.SetBool(true)}}
|
|
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
|
return appCfg, nil
|
|
}
|
|
ds.GetHostOperatingSystemFunc = func(ctx context.Context, hostID uint) (*fleet.OperatingSystem, error) {
|
|
return os, nil
|
|
}
|
|
|
|
ds.GetHostAwaitingConfigurationFunc = func(ctx context.Context, hostUUID string) (bool, error) {
|
|
return false, nil
|
|
}
|
|
|
|
ctx = test.HostContext(ctx, host)
|
|
|
|
cfg, err := svc.GetOrbitConfig(ctx)
|
|
require.NoError(t, err)
|
|
require.False(t, cfg.Notifications.RunDiskEncryptionEscrow)
|
|
|
|
host.OSVersion = "Fedora 38.0"
|
|
cfg, err = svc.GetOrbitConfig(ctx)
|
|
require.NoError(t, err)
|
|
require.False(t, cfg.Notifications.RunDiskEncryptionEscrow)
|
|
})
|
|
|
|
t.Run("pending escrow sets config flag and clears in DB", func(t *testing.T) {
|
|
ds, svc, ctx, host, team := setupEscrowContext()
|
|
|
|
// no-team
|
|
cfg, err := svc.GetOrbitConfig(ctx)
|
|
require.NoError(t, err)
|
|
require.True(t, cfg.Notifications.RunDiskEncryptionEscrow)
|
|
require.True(t, ds.ClearPendingEscrowFuncInvoked)
|
|
|
|
// with team
|
|
ds.ClearPendingEscrowFuncInvoked = false
|
|
host.TeamID = ptr.Uint(team.ID)
|
|
cfg, err = svc.GetOrbitConfig(ctx)
|
|
require.NoError(t, err)
|
|
require.True(t, cfg.Notifications.RunDiskEncryptionEscrow)
|
|
require.True(t, ds.ClearPendingEscrowFuncInvoked)
|
|
|
|
// ignore clear escrow errors
|
|
ds.ClearPendingEscrowFuncInvoked = false
|
|
ds.ClearPendingEscrowFunc = func(ctx context.Context, hostID uint) error {
|
|
return errors.New("clear pending escrow")
|
|
}
|
|
cfg, err = svc.GetOrbitConfig(ctx)
|
|
require.NoError(t, err)
|
|
require.True(t, cfg.Notifications.RunDiskEncryptionEscrow)
|
|
require.True(t, ds.ClearPendingEscrowFuncInvoked)
|
|
})
|
|
}
|
|
|
|
func TestOrbitLUKSDataSave(t *testing.T) {
|
|
t.Run("when private key is set", func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
license := &fleet.LicenseInfo{Tier: fleet.TierPremium}
|
|
opts := &TestServerOpts{License: license, SkipCreateTestUsers: true}
|
|
svc, ctx := newTestService(t, ds, nil, nil, opts)
|
|
host := &fleet.Host{
|
|
OsqueryHostID: ptr.String("test"),
|
|
ID: 1,
|
|
}
|
|
ctx = test.HostContext(ctx, host)
|
|
|
|
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
|
return &fleet.AppConfig{
|
|
MDM: fleet.MDM{
|
|
EnableDiskEncryption: optjson.SetBool(true),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
opts.ActivityMock.NewActivityFunc = func(_ context.Context, _ *activity_api.User, activity activity_api.ActivityDetails) error {
|
|
require.Equal(t, activity.ActivityName(), fleet.ActivityTypeEscrowedDiskEncryptionKey{}.ActivityName())
|
|
return nil
|
|
}
|
|
|
|
expectedErrorMessage := "There was an error."
|
|
ds.ReportEscrowErrorFunc = func(ctx context.Context, hostID uint, err string) error {
|
|
require.Equal(t, expectedErrorMessage, err)
|
|
return nil
|
|
}
|
|
|
|
// test reporting client errors
|
|
err := svc.EscrowLUKSData(ctx, "foo", "bar", nil, expectedErrorMessage, "")
|
|
require.NoError(t, err)
|
|
require.True(t, ds.ReportEscrowErrorFuncInvoked)
|
|
|
|
// blank passphrase
|
|
ds.ReportEscrowErrorFuncInvoked = false
|
|
expectedErrorMessage = "passphrase, salt, and key_slot must be provided to escrow LUKS data"
|
|
err = svc.EscrowLUKSData(ctx, "", "bar", new(uint(0)), "", "")
|
|
require.Error(t, err)
|
|
require.True(t, ds.ReportEscrowErrorFuncInvoked)
|
|
|
|
ds.ReportEscrowErrorFuncInvoked = false
|
|
passphrase, salt := "foo", ""
|
|
var keySlot *uint
|
|
ds.SaveLUKSDataFunc = func(ctx context.Context, incomingHost *fleet.Host, encryptedBase64Passphrase string,
|
|
encryptedBase64Salt string, keySlotToPersist *uint,
|
|
) (bool, error) {
|
|
require.Equal(t, host.ID, incomingHost.ID)
|
|
key := config.TestConfig().Server.PrivateKey
|
|
|
|
decryptedPassphrase, err := mdm.DecodeAndDecrypt(encryptedBase64Passphrase, key)
|
|
require.NoError(t, err)
|
|
require.Equal(t, passphrase, decryptedPassphrase)
|
|
|
|
decryptedSalt, err := mdm.DecodeAndDecrypt(encryptedBase64Salt, key)
|
|
require.NoError(t, err)
|
|
require.Equal(t, salt, decryptedSalt)
|
|
|
|
require.Equal(t, keySlot, keySlotToPersist)
|
|
|
|
return true, nil
|
|
}
|
|
|
|
// with no salt
|
|
err = svc.EscrowLUKSData(ctx, passphrase, salt, keySlot, "", "")
|
|
require.Error(t, err)
|
|
require.True(t, ds.ReportEscrowErrorFuncInvoked)
|
|
require.False(t, ds.SaveLUKSDataFuncInvoked)
|
|
|
|
// with no key slot
|
|
ds.ReportEscrowErrorFuncInvoked = false
|
|
salt = "baz"
|
|
err = svc.EscrowLUKSData(ctx, passphrase, salt, keySlot, "", "")
|
|
require.Error(t, err)
|
|
require.True(t, ds.ReportEscrowErrorFuncInvoked)
|
|
require.False(t, ds.SaveLUKSDataFuncInvoked)
|
|
|
|
// with salt and key slot
|
|
keySlot = ptr.Uint(0)
|
|
ds.ReportEscrowErrorFuncInvoked = false
|
|
err = svc.EscrowLUKSData(ctx, passphrase, salt, keySlot, "", "")
|
|
require.NoError(t, err)
|
|
require.False(t, ds.ReportEscrowErrorFuncInvoked)
|
|
require.True(t, ds.SaveLUKSDataFuncInvoked)
|
|
require.True(t, opts.ActivityMock.NewActivityFuncInvoked)
|
|
})
|
|
|
|
t.Run("recovery key escrow has no salt or key slot", func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
license := &fleet.LicenseInfo{Tier: fleet.TierPremium}
|
|
opts := &TestServerOpts{License: license, SkipCreateTestUsers: true}
|
|
svc, ctx := newTestService(t, ds, nil, nil, opts)
|
|
host := &fleet.Host{
|
|
OsqueryHostID: new("test"),
|
|
ID: 1,
|
|
}
|
|
ctx = test.HostContext(ctx, host)
|
|
|
|
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
|
return &fleet.AppConfig{
|
|
MDM: fleet.MDM{
|
|
EnableDiskEncryption: optjson.SetBool(true),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
opts.ActivityMock.NewActivityFunc = func(_ context.Context, _ *activity_api.User, activity activity_api.ActivityDetails) error {
|
|
require.Equal(t, activity.ActivityName(), fleet.ActivityTypeEscrowedDiskEncryptionKey{}.ActivityName())
|
|
return nil
|
|
}
|
|
|
|
ds.ReportEscrowErrorFunc = func(ctx context.Context, hostID uint, err string) error {
|
|
return nil
|
|
}
|
|
|
|
recoveryKey := "55055-39320-64491-48436-47667-15525-36879-32875"
|
|
ds.SaveLUKSDataFunc = func(ctx context.Context, incomingHost *fleet.Host, encryptedBase64Passphrase string,
|
|
encryptedBase64Salt string, keySlotToPersist *uint,
|
|
) (bool, error) {
|
|
require.Equal(t, host.ID, incomingHost.ID)
|
|
key := config.TestConfig().Server.PrivateKey
|
|
|
|
decrypted, err := mdm.DecodeAndDecrypt(encryptedBase64Passphrase, key)
|
|
require.NoError(t, err)
|
|
require.Equal(t, recoveryKey, decrypted)
|
|
|
|
// snapd owns the LUKS key slots, so a recovery key has no salt or
|
|
// numeric key slot to escrow.
|
|
require.Empty(t, encryptedBase64Salt)
|
|
require.Nil(t, keySlotToPersist)
|
|
|
|
return true, nil
|
|
}
|
|
|
|
// A recovery key requires no salt or key slot.
|
|
err := svc.EscrowLUKSData(ctx, recoveryKey, "", nil, "", fleet.LUKSKeyTypeRecoveryKey)
|
|
require.NoError(t, err)
|
|
require.False(t, ds.ReportEscrowErrorFuncInvoked)
|
|
require.True(t, ds.SaveLUKSDataFuncInvoked)
|
|
require.True(t, opts.ActivityMock.NewActivityFuncInvoked)
|
|
|
|
// A recovery key escrow with no key still fails validation.
|
|
ds.SaveLUKSDataFuncInvoked = false
|
|
err = svc.EscrowLUKSData(ctx, "", "", nil, "", fleet.LUKSKeyTypeRecoveryKey)
|
|
require.Error(t, err)
|
|
require.False(t, ds.SaveLUKSDataFuncInvoked)
|
|
|
|
// Stray salt / key slot on the recovery-key path are rejected, not
|
|
// silently discarded — those fields are meaningless when snapd owns the
|
|
// LUKS key slots, and accepting them would hide client bugs.
|
|
ds.SaveLUKSDataFuncInvoked = false
|
|
err = svc.EscrowLUKSData(ctx, recoveryKey, "some-salt", nil, "", fleet.LUKSKeyTypeRecoveryKey)
|
|
require.Error(t, err)
|
|
require.False(t, ds.SaveLUKSDataFuncInvoked)
|
|
|
|
ds.SaveLUKSDataFuncInvoked = false
|
|
strayKeySlot := uint(0)
|
|
err = svc.EscrowLUKSData(ctx, recoveryKey, "", &strayKeySlot, "", fleet.LUKSKeyTypeRecoveryKey)
|
|
require.Error(t, err)
|
|
require.False(t, ds.SaveLUKSDataFuncInvoked)
|
|
})
|
|
|
|
t.Run("fail when no/invalid private key is set", func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
license := &fleet.LicenseInfo{Tier: fleet.TierPremium}
|
|
host := &fleet.Host{
|
|
OsqueryHostID: ptr.String("test"),
|
|
ID: 1,
|
|
}
|
|
|
|
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
|
return &fleet.AppConfig{
|
|
MDM: fleet.MDM{
|
|
EnableDiskEncryption: optjson.SetBool(true),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
expectedErrorMessage := "internal error: missing server private key"
|
|
ds.ReportEscrowErrorFunc = func(ctx context.Context, hostID uint, err string) error {
|
|
require.Equal(t, expectedErrorMessage, err)
|
|
return nil
|
|
}
|
|
|
|
cfg := config.TestConfig()
|
|
cfg.Server.PrivateKey = ""
|
|
svc, ctx := newTestServiceWithConfig(t, ds, cfg, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true})
|
|
ctx = test.HostContext(ctx, host)
|
|
err := svc.EscrowLUKSData(ctx, "foo", "bar", new(uint(0)), "", "")
|
|
require.Error(t, err)
|
|
require.True(t, ds.ReportEscrowErrorFuncInvoked)
|
|
|
|
expectedErrorMessage = "internal error: could not encrypt LUKS data: create new cipher: crypto/aes: invalid key size 7"
|
|
ds.ReportEscrowErrorFuncInvoked = false
|
|
cfg.Server.PrivateKey = "invalid"
|
|
svc, ctx = newTestServiceWithConfig(t, ds, cfg, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true})
|
|
ctx = test.HostContext(ctx, host)
|
|
err = svc.EscrowLUKSData(ctx, "foo", "bar", new(uint(0)), "", "")
|
|
require.Error(t, err)
|
|
require.True(t, ds.ReportEscrowErrorFuncInvoked)
|
|
})
|
|
}
|
|
|
|
func TestGetOrbitConfigNudge(t *testing.T) {
|
|
t.Run("missing values in AppConfig", func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
license := &fleet.LicenseInfo{Tier: fleet.TierPremium}
|
|
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true})
|
|
appCfg := &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}}
|
|
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
|
return appCfg, nil
|
|
}
|
|
os := &fleet.OperatingSystem{
|
|
Platform: "darwin",
|
|
Version: "12.2",
|
|
}
|
|
ds.GetHostOperatingSystemFunc = func(ctx context.Context, hostID uint) (*fleet.OperatingSystem, error) {
|
|
return os, nil
|
|
}
|
|
ds.ListReadyToExecuteScriptsForHostFunc = func(ctx context.Context, hostID uint, onlyShowInternal bool) ([]*fleet.HostScriptResult, error) {
|
|
return nil, nil
|
|
}
|
|
ds.ListReadyToExecuteSoftwareInstallsFunc = func(ctx context.Context, hostID uint) ([]string, error) {
|
|
return nil, nil
|
|
}
|
|
ds.IsHostConnectedToFleetMDMFunc = func(ctx context.Context, host *fleet.Host) (bool, error) {
|
|
return true, nil
|
|
}
|
|
ds.IsHostPendingEscrowFunc = func(ctx context.Context, hostID uint) bool {
|
|
return false
|
|
}
|
|
|
|
ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) {
|
|
return &fleet.HostMDM{
|
|
IsServer: false,
|
|
InstalledFromDep: true,
|
|
Enrolled: true,
|
|
Name: fleet.WellKnownMDMFleet,
|
|
ConnectedToFleet: true,
|
|
}, nil
|
|
}
|
|
|
|
ds.GetHostAwaitingConfigurationFunc = func(ctx context.Context, hostUUID string) (bool, error) {
|
|
return false, nil
|
|
}
|
|
|
|
ctx = test.HostContext(ctx, &fleet.Host{
|
|
OsqueryHostID: ptr.String("test"),
|
|
ID: 1,
|
|
})
|
|
|
|
cfg, err := svc.GetOrbitConfig(ctx)
|
|
require.NoError(t, err)
|
|
require.Empty(t, cfg.NudgeConfig)
|
|
require.True(t, ds.AppConfigFuncInvoked)
|
|
ds.AppConfigFuncInvoked = false
|
|
|
|
appCfg.MDM.MacOSUpdates.Deadline = optjson.SetString("2022-04-01")
|
|
cfg, err = svc.GetOrbitConfig(ctx)
|
|
require.NoError(t, err)
|
|
require.Empty(t, cfg.NudgeConfig)
|
|
require.True(t, ds.AppConfigFuncInvoked)
|
|
ds.AppConfigFuncInvoked = false
|
|
|
|
appCfg.MDM.MacOSUpdates.MinimumVersion = optjson.SetString("2022-04-01")
|
|
cfg, err = svc.GetOrbitConfig(ctx)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, cfg.NudgeConfig)
|
|
require.True(t, ds.AppConfigFuncInvoked)
|
|
ds.AppConfigFuncInvoked = false
|
|
})
|
|
|
|
t.Run("missing values in TeamConfig", func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
license := &fleet.LicenseInfo{Tier: fleet.TierPremium}
|
|
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true})
|
|
appCfg := &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}}
|
|
appCfg.MDM.MacOSUpdates.MinimumVersion = optjson.SetString("2022-04-01")
|
|
appCfg.MDM.MacOSUpdates.Deadline = optjson.SetString("2022-04-01")
|
|
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
|
return appCfg, nil
|
|
}
|
|
os := &fleet.OperatingSystem{
|
|
Platform: "darwin",
|
|
Version: "12.2",
|
|
}
|
|
ds.GetHostOperatingSystemFunc = func(ctx context.Context, hostID uint) (*fleet.OperatingSystem, error) {
|
|
return os, nil
|
|
}
|
|
ds.ListReadyToExecuteSoftwareInstallsFunc = func(ctx context.Context, hostID uint) ([]string, error) {
|
|
return nil, nil
|
|
}
|
|
team := fleet.Team{ID: 1}
|
|
teamMDM := fleet.TeamMDM{}
|
|
ds.TeamMDMConfigFunc = func(ctx context.Context, teamID uint) (*fleet.TeamMDM, error) {
|
|
require.Equal(t, team.ID, teamID)
|
|
return &teamMDM, nil
|
|
}
|
|
ds.TeamAgentOptionsFunc = func(ctx context.Context, id uint) (*json.RawMessage, error) {
|
|
return ptr.RawMessage(json.RawMessage(`{}`)), nil
|
|
}
|
|
ds.ListReadyToExecuteScriptsForHostFunc = func(ctx context.Context, hostID uint, onlyShowInternal bool) ([]*fleet.HostScriptResult, error) {
|
|
return nil, nil
|
|
}
|
|
ds.IsHostConnectedToFleetMDMFunc = func(ctx context.Context, host *fleet.Host) (bool, error) {
|
|
return true, nil
|
|
}
|
|
ds.IsHostPendingEscrowFunc = func(ctx context.Context, hostID uint) bool {
|
|
return false
|
|
}
|
|
|
|
ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) {
|
|
return &fleet.HostMDM{
|
|
IsServer: false,
|
|
InstalledFromDep: true,
|
|
Enrolled: true,
|
|
Name: fleet.WellKnownMDMFleet,
|
|
ConnectedToFleet: true,
|
|
}, nil
|
|
}
|
|
|
|
ds.GetHostAwaitingConfigurationFunc = func(ctx context.Context, hostUUID string) (bool, error) {
|
|
return false, nil
|
|
}
|
|
|
|
ctx = test.HostContext(ctx, &fleet.Host{
|
|
OsqueryHostID: ptr.String("test"),
|
|
ID: 1,
|
|
TeamID: ptr.Uint(team.ID),
|
|
})
|
|
|
|
cfg, err := svc.GetOrbitConfig(ctx)
|
|
require.NoError(t, err)
|
|
require.Empty(t, cfg.NudgeConfig)
|
|
require.True(t, ds.AppConfigFuncInvoked)
|
|
require.True(t, ds.TeamMDMConfigFuncInvoked)
|
|
ds.AppConfigFuncInvoked = false
|
|
ds.TeamMDMConfigFuncInvoked = false
|
|
|
|
teamMDM.MacOSUpdates.Deadline = optjson.SetString("2022-04-01")
|
|
cfg, err = svc.GetOrbitConfig(ctx)
|
|
require.NoError(t, err)
|
|
require.Empty(t, cfg.NudgeConfig)
|
|
require.True(t, ds.AppConfigFuncInvoked)
|
|
require.True(t, ds.TeamMDMConfigFuncInvoked)
|
|
ds.AppConfigFuncInvoked = false
|
|
ds.TeamMDMConfigFuncInvoked = false
|
|
|
|
teamMDM.MacOSUpdates.MinimumVersion = optjson.SetString("2022-04-01")
|
|
cfg, err = svc.GetOrbitConfig(ctx)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, cfg.NudgeConfig)
|
|
require.True(t, ds.AppConfigFuncInvoked)
|
|
require.True(t, ds.TeamMDMConfigFuncInvoked)
|
|
ds.AppConfigFuncInvoked = false
|
|
ds.TeamMDMConfigFuncInvoked = false
|
|
})
|
|
|
|
t.Run("non-eligible MDM status", func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
license := &fleet.LicenseInfo{Tier: fleet.TierPremium}
|
|
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true})
|
|
os := &fleet.OperatingSystem{
|
|
Platform: "darwin",
|
|
Version: "12.2",
|
|
}
|
|
ds.GetHostOperatingSystemFunc = func(ctx context.Context, hostID uint) (*fleet.OperatingSystem, error) {
|
|
return os, nil
|
|
}
|
|
appCfg := &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}}
|
|
appCfg.MDM.MacOSUpdates.Deadline = optjson.SetString("2022-04-01")
|
|
appCfg.MDM.MacOSUpdates.MinimumVersion = optjson.SetString("2022-04-01")
|
|
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
|
return appCfg, nil
|
|
}
|
|
ds.ListReadyToExecuteScriptsForHostFunc = func(ctx context.Context, hostID uint, onlyShowInternal bool) ([]*fleet.HostScriptResult, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
team := fleet.Team{ID: 1}
|
|
teamMDM := fleet.TeamMDM{}
|
|
teamMDM.MacOSUpdates.Deadline = optjson.SetString("2022-04-01")
|
|
teamMDM.MacOSUpdates.MinimumVersion = optjson.SetString("12.1")
|
|
ds.TeamMDMConfigFunc = func(ctx context.Context, teamID uint) (*fleet.TeamMDM, error) {
|
|
require.Equal(t, team.ID, teamID)
|
|
return &teamMDM, nil
|
|
}
|
|
ds.TeamAgentOptionsFunc = func(ctx context.Context, id uint) (*json.RawMessage, error) {
|
|
return ptr.RawMessage(json.RawMessage(`{}`)), nil
|
|
}
|
|
ds.ListReadyToExecuteSoftwareInstallsFunc = func(ctx context.Context, hostID uint) ([]string, error) {
|
|
return nil, nil
|
|
}
|
|
// GetOrbitConfig derives the Fleet-MDM connection state from GetHostMDM
|
|
// (ConnectedToFleet).
|
|
var connectedToFleetMDM bool
|
|
ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) {
|
|
return &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet, ConnectedToFleet: connectedToFleetMDM}, nil
|
|
}
|
|
|
|
ds.GetHostAwaitingConfigurationFunc = func(ctx context.Context, hostUUID string) (bool, error) {
|
|
return false, nil
|
|
}
|
|
ds.IsHostPendingEscrowFunc = func(ctx context.Context, hostID uint) bool {
|
|
return false
|
|
}
|
|
|
|
checkEmptyNudgeConfig := func(h *fleet.Host) {
|
|
ctx := test.HostContext(ctx, h)
|
|
cfg, err := svc.GetOrbitConfig(ctx)
|
|
require.NoError(t, err)
|
|
require.Empty(t, cfg.NudgeConfig)
|
|
require.True(t, ds.AppConfigFuncInvoked)
|
|
ds.AppConfigFuncInvoked = false
|
|
}
|
|
|
|
checkHostVariations := func(h *fleet.Host) {
|
|
// host is osquery-enrolled but not connected to Fleet MDM
|
|
connectedToFleetMDM = false
|
|
checkEmptyNudgeConfig(h)
|
|
|
|
// host is connected to Fleet MDM but not osquery-enrolled
|
|
connectedToFleetMDM = true
|
|
h.OsqueryHostID = nil
|
|
checkEmptyNudgeConfig(h)
|
|
}
|
|
|
|
// global host
|
|
checkHostVariations(&fleet.Host{
|
|
OsqueryHostID: ptr.String("test"),
|
|
Platform: "darwin",
|
|
})
|
|
|
|
// team host
|
|
checkHostVariations(&fleet.Host{
|
|
OsqueryHostID: ptr.String("test"),
|
|
TeamID: ptr.Uint(team.ID),
|
|
Platform: "darwin",
|
|
})
|
|
})
|
|
|
|
t.Run("no-nudge on macos versions greater than 14", func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
license := &fleet.LicenseInfo{Tier: fleet.TierPremium}
|
|
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true})
|
|
os := &fleet.OperatingSystem{
|
|
Platform: "darwin",
|
|
Version: "12.2",
|
|
}
|
|
host := &fleet.Host{
|
|
OsqueryHostID: ptr.String("test"),
|
|
ID: 1,
|
|
}
|
|
|
|
team := fleet.Team{ID: 1}
|
|
teamMDM := fleet.TeamMDM{}
|
|
teamMDM.MacOSUpdates.Deadline = optjson.SetString("2022-04-01")
|
|
teamMDM.MacOSUpdates.MinimumVersion = optjson.SetString("12.1")
|
|
ds.TeamMDMConfigFunc = func(ctx context.Context, teamID uint) (*fleet.TeamMDM, error) {
|
|
require.Equal(t, team.ID, teamID)
|
|
return &teamMDM, nil
|
|
}
|
|
ds.TeamAgentOptionsFunc = func(ctx context.Context, id uint) (*json.RawMessage, error) {
|
|
return ptr.RawMessage(json.RawMessage(`{}`)), nil
|
|
}
|
|
ds.ListReadyToExecuteScriptsForHostFunc = func(ctx context.Context, hostID uint, onlyShowInternal bool) ([]*fleet.HostScriptResult, error) {
|
|
return nil, nil
|
|
}
|
|
ds.ListReadyToExecuteSoftwareInstallsFunc = func(ctx context.Context, hostID uint) ([]string, error) {
|
|
return nil, nil
|
|
}
|
|
ds.IsHostConnectedToFleetMDMFunc = func(ctx context.Context, host *fleet.Host) (bool, error) {
|
|
return true, nil
|
|
}
|
|
ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) {
|
|
return &fleet.HostMDM{
|
|
IsServer: false,
|
|
InstalledFromDep: true,
|
|
Enrolled: true,
|
|
Name: fleet.WellKnownMDMFleet,
|
|
ConnectedToFleet: true,
|
|
}, nil
|
|
}
|
|
ds.IsHostPendingEscrowFunc = func(ctx context.Context, hostID uint) bool {
|
|
return false
|
|
}
|
|
|
|
appCfg := &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}}
|
|
appCfg.MDM.MacOSUpdates.Deadline = optjson.SetString("2022-04-01")
|
|
appCfg.MDM.MacOSUpdates.MinimumVersion = optjson.SetString("12.3")
|
|
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
|
return appCfg, nil
|
|
}
|
|
ds.GetHostOperatingSystemFunc = func(ctx context.Context, hostID uint) (*fleet.OperatingSystem, error) {
|
|
return os, nil
|
|
}
|
|
|
|
ds.GetHostAwaitingConfigurationFunc = func(ctx context.Context, hostUUID string) (bool, error) {
|
|
return false, nil
|
|
}
|
|
|
|
ctx = test.HostContext(ctx, host)
|
|
|
|
// Version < 14 gets nudge
|
|
host.ID = 1
|
|
cfg, err := svc.GetOrbitConfig(ctx)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, cfg.NudgeConfig)
|
|
require.True(t, ds.GetHostOperatingSystemFuncInvoked)
|
|
|
|
// Version > 14 gets no nudge
|
|
os.Version = "14.1"
|
|
ds.GetHostOperatingSystemFuncInvoked = false
|
|
cfg, err = svc.GetOrbitConfig(ctx)
|
|
require.NoError(t, err)
|
|
require.False(t, cfg.Notifications.RunDiskEncryptionEscrow)
|
|
require.Empty(t, cfg.NudgeConfig)
|
|
require.True(t, ds.GetHostOperatingSystemFuncInvoked)
|
|
|
|
// windows gets no nudge
|
|
os.Platform = "windows"
|
|
ds.GetHostOperatingSystemFuncInvoked = false
|
|
cfg, err = svc.GetOrbitConfig(ctx)
|
|
require.NoError(t, err)
|
|
require.Empty(t, cfg.NudgeConfig)
|
|
require.True(t, ds.GetHostOperatingSystemFuncInvoked)
|
|
|
|
//// team section below
|
|
host.TeamID = ptr.Uint(team.ID)
|
|
os.Platform = "darwin"
|
|
os.Version = "12.1"
|
|
|
|
// Version < 14 gets nudge
|
|
host.ID = 1
|
|
cfg, err = svc.GetOrbitConfig(ctx)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, cfg.NudgeConfig)
|
|
require.True(t, ds.GetHostOperatingSystemFuncInvoked)
|
|
|
|
// Version > 14 gets no nudge
|
|
os.Version = "14.1"
|
|
ds.GetHostOperatingSystemFuncInvoked = false
|
|
cfg, err = svc.GetOrbitConfig(ctx)
|
|
require.NoError(t, err)
|
|
require.Empty(t, cfg.NudgeConfig)
|
|
require.True(t, ds.GetHostOperatingSystemFuncInvoked)
|
|
|
|
// windows gets no nudge
|
|
os.Platform = "windows"
|
|
ds.GetHostOperatingSystemFuncInvoked = false
|
|
cfg, err = svc.GetOrbitConfig(ctx)
|
|
require.NoError(t, err)
|
|
require.Empty(t, cfg.NudgeConfig)
|
|
require.True(t, ds.GetHostOperatingSystemFuncInvoked)
|
|
})
|
|
}
|
|
|
|
func TestGetOrbitConfigScriptTimeoutFallback(t *testing.T) {
|
|
setupCtx := func(teamAgentOpts, globalAgentOpts *json.RawMessage) (fleet.Service, context.Context, *mock.Store) {
|
|
ds := new(mock.Store)
|
|
license := &fleet.LicenseInfo{Tier: fleet.TierPremium}
|
|
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true})
|
|
|
|
team := fleet.Team{ID: 1}
|
|
ds.TeamMDMConfigFunc = func(ctx context.Context, teamID uint) (*fleet.TeamMDM, error) {
|
|
return &fleet.TeamMDM{}, nil
|
|
}
|
|
ds.TeamAgentOptionsFunc = func(ctx context.Context, id uint) (*json.RawMessage, error) {
|
|
return teamAgentOpts, nil
|
|
}
|
|
ds.ListReadyToExecuteScriptsForHostFunc = func(ctx context.Context, hostID uint, onlyShowInternal bool) ([]*fleet.HostScriptResult, error) {
|
|
return nil, nil
|
|
}
|
|
ds.ListReadyToExecuteSoftwareInstallsFunc = func(ctx context.Context, hostID uint) ([]string, error) {
|
|
return nil, nil
|
|
}
|
|
ds.IsHostConnectedToFleetMDMFunc = func(ctx context.Context, host *fleet.Host) (bool, error) {
|
|
return false, nil
|
|
}
|
|
ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) {
|
|
return nil, newNotFoundError()
|
|
}
|
|
ds.IsHostPendingEscrowFunc = func(ctx context.Context, hostID uint) bool {
|
|
return false
|
|
}
|
|
ds.GetHostAwaitingConfigurationFunc = func(ctx context.Context, hostUUID string) (bool, error) {
|
|
return false, nil
|
|
}
|
|
appCfg := &fleet.AppConfig{AgentOptions: globalAgentOpts}
|
|
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
|
return appCfg, nil
|
|
}
|
|
|
|
ctx = test.HostContext(ctx, &fleet.Host{
|
|
OsqueryHostID: ptr.String("test"),
|
|
ID: 1,
|
|
Platform: "ubuntu",
|
|
TeamID: new(team.ID),
|
|
})
|
|
return svc, ctx, ds
|
|
}
|
|
|
|
t.Run("team timeout set wins over global", func(t *testing.T) {
|
|
team := new(json.RawMessage(`{"script_execution_timeout": 600}`))
|
|
global := new(json.RawMessage(`{"config": {}, "script_execution_timeout": 1200}`))
|
|
svc, ctx, _ := setupCtx(team, global)
|
|
|
|
cfg, err := svc.GetOrbitConfig(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, 600, cfg.ScriptExeTimeout)
|
|
})
|
|
|
|
t.Run("team timeout unset falls back to global", func(t *testing.T) {
|
|
team := new(json.RawMessage(`{}`))
|
|
global := new(json.RawMessage(`{"config": {}, "script_execution_timeout": 1200}`))
|
|
svc, ctx, _ := setupCtx(team, global)
|
|
|
|
cfg, err := svc.GetOrbitConfig(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, 1200, cfg.ScriptExeTimeout)
|
|
})
|
|
|
|
t.Run("team timeout zero falls back to global", func(t *testing.T) {
|
|
team := new(json.RawMessage(`{"script_execution_timeout": 0}`))
|
|
global := new(json.RawMessage(`{"config": {}, "script_execution_timeout": 900}`))
|
|
svc, ctx, _ := setupCtx(team, global)
|
|
|
|
cfg, err := svc.GetOrbitConfig(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, 900, cfg.ScriptExeTimeout)
|
|
})
|
|
|
|
t.Run("team and global both unset", func(t *testing.T) {
|
|
team := new(json.RawMessage(`{}`))
|
|
global := new(json.RawMessage(`{"config": {}}`))
|
|
svc, ctx, _ := setupCtx(team, global)
|
|
|
|
cfg, err := svc.GetOrbitConfig(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, 0, cfg.ScriptExeTimeout)
|
|
})
|
|
|
|
t.Run("nil global agent options, team unset", func(t *testing.T) {
|
|
team := new(json.RawMessage(`{}`))
|
|
svc, ctx, _ := setupCtx(team, nil)
|
|
|
|
cfg, err := svc.GetOrbitConfig(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, 0, cfg.ScriptExeTimeout)
|
|
})
|
|
}
|
|
|
|
func TestGetSoftwareInstallDetails(t *testing.T) {
|
|
t.Run("hosts can't get each others installers", func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
license := &fleet.LicenseInfo{Tier: fleet.TierPremium}
|
|
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true})
|
|
|
|
ds.GetSoftwareInstallDetailsFunc = func(ctx context.Context, executionId string) (*fleet.SoftwareInstallDetails, error) {
|
|
return &fleet.SoftwareInstallDetails{
|
|
HostID: 1,
|
|
}, nil
|
|
}
|
|
|
|
ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) {
|
|
return &fleet.HostMDM{
|
|
IsServer: false,
|
|
InstalledFromDep: true,
|
|
Enrolled: true,
|
|
Name: fleet.WellKnownMDMFleet,
|
|
ConnectedToFleet: true,
|
|
}, nil
|
|
}
|
|
|
|
goodCtx := test.HostContext(ctx, &fleet.Host{
|
|
OsqueryHostID: ptr.String("test"),
|
|
ID: 1,
|
|
})
|
|
|
|
badCtx := test.HostContext(ctx, &fleet.Host{
|
|
OsqueryHostID: ptr.String("test"),
|
|
ID: 2,
|
|
})
|
|
|
|
d1, err := svc.GetSoftwareInstallDetails(goodCtx, "")
|
|
require.NoError(t, err)
|
|
require.Equal(t, uint(1), d1.HostID)
|
|
|
|
d2, err := svc.GetSoftwareInstallDetails(badCtx, "")
|
|
require.Error(t, err)
|
|
require.Nil(t, d2)
|
|
})
|
|
}
|
|
|
|
func TestShouldRetrySoftwareInstall(t *testing.T) {
|
|
svc := &Service{
|
|
logger: slog.New(slog.DiscardHandler),
|
|
}
|
|
ctx := context.Background()
|
|
|
|
t.Run("nil attempt number returns false", func(t *testing.T) {
|
|
hsi := &fleet.HostSoftwareInstallerResult{
|
|
AttemptNumber: nil,
|
|
}
|
|
shouldRetry, err := svc.shouldRetrySoftwareInstall(ctx, hsi)
|
|
require.NoError(t, err)
|
|
require.False(t, shouldRetry)
|
|
})
|
|
|
|
t.Run("attempt below max returns true", func(t *testing.T) {
|
|
for _, attempt := range []int{1, 2} {
|
|
hsi := &fleet.HostSoftwareInstallerResult{
|
|
AttemptNumber: ptr.Int(attempt),
|
|
}
|
|
shouldRetry, err := svc.shouldRetrySoftwareInstall(ctx, hsi)
|
|
require.NoError(t, err)
|
|
require.True(t, shouldRetry, "attempt %d should retry", attempt)
|
|
}
|
|
})
|
|
|
|
t.Run("attempt at max returns false", func(t *testing.T) {
|
|
hsi := &fleet.HostSoftwareInstallerResult{
|
|
AttemptNumber: ptr.Int(fleet.MaxSoftwareInstallAttempts),
|
|
}
|
|
shouldRetry, err := svc.shouldRetrySoftwareInstall(ctx, hsi)
|
|
require.NoError(t, err)
|
|
require.False(t, shouldRetry)
|
|
})
|
|
|
|
t.Run("attempt above max returns false", func(t *testing.T) {
|
|
hsi := &fleet.HostSoftwareInstallerResult{
|
|
AttemptNumber: ptr.Int(fleet.MaxSoftwareInstallAttempts + 1),
|
|
}
|
|
shouldRetry, err := svc.shouldRetrySoftwareInstall(ctx, hsi)
|
|
require.NoError(t, err)
|
|
require.False(t, shouldRetry)
|
|
})
|
|
}
|
|
|
|
func TestRetrySoftwareInstall(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
svc := &Service{
|
|
ds: ds,
|
|
logger: slog.New(slog.DiscardHandler),
|
|
}
|
|
ctx := context.Background()
|
|
|
|
installerID := uint(42)
|
|
userID := uint(7)
|
|
host := &fleet.Host{ID: 1}
|
|
hsi := &fleet.HostSoftwareInstallerResult{
|
|
SoftwareInstallerID: &installerID,
|
|
SelfService: true,
|
|
UserID: &userID,
|
|
AttemptNumber: ptr.Int(1),
|
|
}
|
|
|
|
var capturedOpts fleet.HostSoftwareInstallOptions
|
|
var capturedInstallerID uint
|
|
ds.InsertSoftwareInstallRequestFunc = func(ctx context.Context, hostID uint, softwareInstallerID uint, opts fleet.HostSoftwareInstallOptions) (string, error) {
|
|
require.Equal(t, host.ID, hostID)
|
|
capturedInstallerID = softwareInstallerID
|
|
capturedOpts = opts
|
|
return "new-uuid", nil
|
|
}
|
|
// By default the frozen installer is still the active one for its title.
|
|
ds.ResolveActiveInstallerForRetryFunc = func(ctx context.Context, installerID uint) (uint, error) {
|
|
return installerID, nil
|
|
}
|
|
|
|
t.Run("preserves self-service and user ID", func(t *testing.T) {
|
|
err := svc.retrySoftwareInstall(ctx, host, hsi, false)
|
|
require.NoError(t, err)
|
|
require.True(t, ds.InsertSoftwareInstallRequestFuncInvoked)
|
|
require.Equal(t, installerID, capturedInstallerID)
|
|
require.True(t, capturedOpts.SelfService)
|
|
require.NotNil(t, capturedOpts.UserID)
|
|
require.Equal(t, userID, *capturedOpts.UserID)
|
|
require.False(t, capturedOpts.ForSetupExperience)
|
|
require.True(t, capturedOpts.WithRetries)
|
|
})
|
|
|
|
t.Run("passes setup experience flag", func(t *testing.T) {
|
|
ds.InsertSoftwareInstallRequestFuncInvoked = false
|
|
err := svc.retrySoftwareInstall(ctx, host, hsi, true)
|
|
require.NoError(t, err)
|
|
require.True(t, ds.InsertSoftwareInstallRequestFuncInvoked)
|
|
require.True(t, capturedOpts.ForSetupExperience)
|
|
})
|
|
|
|
t.Run("retries the active installer after a version change", func(t *testing.T) {
|
|
const activeID = uint(99)
|
|
ds.ResolveActiveInstallerForRetryFunc = func(ctx context.Context, gotID uint) (uint, error) {
|
|
require.Equal(t, installerID, gotID)
|
|
return activeID, nil
|
|
}
|
|
ds.InsertSoftwareInstallRequestFuncInvoked = false
|
|
err := svc.retrySoftwareInstall(ctx, host, hsi, false)
|
|
require.NoError(t, err)
|
|
require.True(t, ds.InsertSoftwareInstallRequestFuncInvoked)
|
|
require.Equal(t, activeID, capturedInstallerID, "retry targets the current active installer, not the frozen one")
|
|
})
|
|
}
|
|
|
|
func TestRetryPolicyAutomationSoftwareInstall(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
svc := &Service{ds: ds, logger: slog.New(slog.DiscardHandler)}
|
|
ctx := context.Background()
|
|
|
|
frozenID := uint(42)
|
|
policyID := uint(5)
|
|
host := &fleet.Host{ID: 1}
|
|
hsi := &fleet.HostSoftwareInstallerResult{
|
|
SoftwareInstallerID: &frozenID,
|
|
PolicyID: &policyID,
|
|
AttemptNumber: new(1),
|
|
}
|
|
|
|
var capturedInstallerID uint
|
|
var capturedOpts fleet.HostSoftwareInstallOptions
|
|
ds.InsertSoftwareInstallRequestFunc = func(ctx context.Context, hostID uint, softwareInstallerID uint, opts fleet.HostSoftwareInstallOptions) (string, error) {
|
|
require.Equal(t, host.ID, hostID)
|
|
capturedInstallerID = softwareInstallerID
|
|
capturedOpts = opts
|
|
return "new-uuid", nil
|
|
}
|
|
|
|
t.Run("retries the frozen installer when it is still active", func(t *testing.T) {
|
|
ds.ResolveActiveInstallerForRetryFunc = func(ctx context.Context, id uint) (uint, error) { return id, nil }
|
|
ds.InsertSoftwareInstallRequestFuncInvoked = false
|
|
require.NoError(t, svc.retryPolicyAutomationSoftwareInstall(ctx, host, hsi))
|
|
require.True(t, ds.InsertSoftwareInstallRequestFuncInvoked)
|
|
require.Equal(t, frozenID, capturedInstallerID)
|
|
require.Equal(t, &policyID, capturedOpts.PolicyID)
|
|
})
|
|
|
|
t.Run("retries the active installer after a version change", func(t *testing.T) {
|
|
const activeID = uint(99)
|
|
ds.ResolveActiveInstallerForRetryFunc = func(ctx context.Context, id uint) (uint, error) {
|
|
require.Equal(t, frozenID, id)
|
|
return activeID, nil
|
|
}
|
|
ds.InsertSoftwareInstallRequestFuncInvoked = false
|
|
require.NoError(t, svc.retryPolicyAutomationSoftwareInstall(ctx, host, hsi))
|
|
require.True(t, ds.InsertSoftwareInstallRequestFuncInvoked)
|
|
require.Equal(t, activeID, capturedInstallerID, "policy retry targets the current active installer, not the frozen one")
|
|
require.Equal(t, &policyID, capturedOpts.PolicyID)
|
|
})
|
|
}
|
|
|
|
func TestGetSoftwareInstallerAttemptNumber(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
svc := &Service{
|
|
ds: ds,
|
|
logger: slog.New(slog.DiscardHandler),
|
|
}
|
|
ctx := context.Background()
|
|
host := &fleet.Host{ID: 1}
|
|
|
|
t.Run("returns nil when install not found", func(t *testing.T) {
|
|
ds.GetSoftwareInstallResultsFunc = func(ctx context.Context, installUUID string) (*fleet.HostSoftwareInstallerResult, error) {
|
|
return nil, newNotFoundError()
|
|
}
|
|
result, err := svc.getSoftwareInstallerAttemptNumber(ctx, host, "uuid-1")
|
|
require.NoError(t, err)
|
|
require.Nil(t, result)
|
|
})
|
|
|
|
t.Run("returns nil when software installer ID is nil", func(t *testing.T) {
|
|
ds.GetSoftwareInstallResultsFunc = func(ctx context.Context, installUUID string) (*fleet.HostSoftwareInstallerResult, error) {
|
|
return &fleet.HostSoftwareInstallerResult{SoftwareInstallerID: nil}, nil
|
|
}
|
|
result, err := svc.getSoftwareInstallerAttemptNumber(ctx, host, "uuid-1")
|
|
require.NoError(t, err)
|
|
require.Nil(t, result)
|
|
})
|
|
|
|
t.Run("counts policy install attempts", func(t *testing.T) {
|
|
policyID := uint(10)
|
|
installerID := uint(20)
|
|
ds.GetSoftwareInstallResultsFunc = func(ctx context.Context, installUUID string) (*fleet.HostSoftwareInstallerResult, error) {
|
|
return &fleet.HostSoftwareInstallerResult{
|
|
SoftwareInstallerID: &installerID,
|
|
PolicyID: &policyID,
|
|
}, nil
|
|
}
|
|
ds.CountHostSoftwareInstallAttemptsFunc = func(ctx context.Context, hostID, siID, polID uint) (int, error) {
|
|
require.Equal(t, host.ID, hostID)
|
|
require.Equal(t, installerID, siID)
|
|
require.Equal(t, policyID, polID)
|
|
return 2, nil
|
|
}
|
|
result, err := svc.getSoftwareInstallerAttemptNumber(ctx, host, "uuid-1")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
require.Equal(t, 2, *result)
|
|
require.True(t, ds.CountHostSoftwareInstallAttemptsFuncInvoked)
|
|
})
|
|
|
|
t.Run("returns attempt number from install for non-policy retry-eligible install", func(t *testing.T) {
|
|
installerID := uint(20)
|
|
attemptNum := 2
|
|
ds.GetSoftwareInstallResultsFunc = func(ctx context.Context, installUUID string) (*fleet.HostSoftwareInstallerResult, error) {
|
|
return &fleet.HostSoftwareInstallerResult{
|
|
SoftwareInstallerID: &installerID,
|
|
PolicyID: nil, // non-policy install
|
|
AttemptNumber: &attemptNum,
|
|
}, nil
|
|
}
|
|
ds.CountHostSoftwareInstallAttemptsFuncInvoked = false
|
|
result, err := svc.getSoftwareInstallerAttemptNumber(ctx, host, "uuid-1")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
require.Equal(t, 2, *result)
|
|
require.False(t, ds.CountHostSoftwareInstallAttemptsFuncInvoked)
|
|
})
|
|
|
|
t.Run("returns nil for non-policy install without retry support", func(t *testing.T) {
|
|
installerID := uint(20)
|
|
ds.GetSoftwareInstallResultsFunc = func(ctx context.Context, installUUID string) (*fleet.HostSoftwareInstallerResult, error) {
|
|
return &fleet.HostSoftwareInstallerResult{
|
|
SoftwareInstallerID: &installerID,
|
|
PolicyID: nil, // non-policy install
|
|
AttemptNumber: nil, // not created with WithRetries
|
|
}, nil
|
|
}
|
|
ds.CountHostSoftwareInstallAttemptsFuncInvoked = false
|
|
result, err := svc.getSoftwareInstallerAttemptNumber(ctx, host, "uuid-1")
|
|
require.NoError(t, err)
|
|
require.Nil(t, result)
|
|
require.False(t, ds.CountHostSoftwareInstallAttemptsFuncInvoked)
|
|
})
|
|
}
|
|
|
|
func TestSoftwareInstallReplicaLag(t *testing.T) {
|
|
// Create datastore with dummy replica to simulate replication lag
|
|
opts := &testing_utils.DatastoreTestOptions{DummyReplica: true}
|
|
ds := mysqltest.CreateMySQLDSWithOptions(t, opts)
|
|
defer ds.Close()
|
|
|
|
svc, ctx := newTestService(t, ds, nil, nil)
|
|
|
|
// Create admin user
|
|
user, err := ds.NewUser(ctx, &fleet.User{
|
|
Name: "Admin",
|
|
Password: []byte("p4ssw0rd.123"),
|
|
Email: "admin@example.com",
|
|
GlobalRole: ptr.String(fleet.RoleAdmin),
|
|
})
|
|
require.NoError(t, err)
|
|
ctx = viewer.NewContext(ctx, viewer.Viewer{User: user})
|
|
|
|
// Create a host
|
|
host := test.NewHost(t, ds, "host1", "10.0.0.1", "host1Key", "host1UUID", time.Now())
|
|
opts.RunReplication("hosts")
|
|
|
|
// Create a policy
|
|
policy, err := ds.NewGlobalPolicy(ctx, &user.ID, fleet.PolicyPayload{
|
|
Name: "test policy",
|
|
Query: "SELECT 1;",
|
|
})
|
|
require.NoError(t, err)
|
|
opts.RunReplication("policies")
|
|
|
|
// Create software installer
|
|
payload := &fleet.UploadSoftwareInstallerPayload{
|
|
InstallScript: "echo 'installing'",
|
|
Filename: "test_installer.pkg",
|
|
StorageID: uuid.New().String(),
|
|
Title: "Test Software",
|
|
Version: "1.0.0",
|
|
Source: "apps",
|
|
Platform: "darwin",
|
|
UserID: user.ID,
|
|
TeamID: nil,
|
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
|
}
|
|
installerID, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, payload)
|
|
require.NoError(t, err)
|
|
opts.RunReplication("software_installers", "software_titles")
|
|
|
|
// Mark policy as failing for the host
|
|
_, err = ds.RecordPolicyQueryExecutions(ctx, host, map[uint]*bool{policy.ID: new(false)}, time.Now(), false, nil)
|
|
require.NoError(t, err)
|
|
opts.RunReplication("policy_membership")
|
|
|
|
// simulate Orbit picking up upcoming_activity and activating
|
|
installUUID := uuid.New().String()
|
|
var titleID uint
|
|
mysqltest.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
err := sqlx.GetContext(ctx, q, &titleID,
|
|
`SELECT title_id FROM software_installers WHERE id = ?`, installerID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = q.ExecContext(ctx, `
|
|
INSERT INTO host_software_installs (
|
|
execution_id, host_id, software_installer_id, policy_id,
|
|
installer_filename, version, software_title_id, software_title_name
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, installUUID, host.ID, installerID, policy.ID,
|
|
payload.Filename, payload.Version, titleID, payload.Title)
|
|
return err
|
|
})
|
|
|
|
var attemptNumberBeforeResult *int
|
|
mysqltest.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.GetContext(ctx, q, &attemptNumberBeforeResult,
|
|
`SELECT attempt_number FROM host_software_installs WHERE execution_id = ?`,
|
|
installUUID)
|
|
})
|
|
require.Nil(t, attemptNumberBeforeResult, "attempt_number should be NULL after activation")
|
|
|
|
// Make the activated install available to replica
|
|
opts.RunReplication("host_software_installs")
|
|
|
|
result := &fleet.HostSoftwareInstallResultPayload{
|
|
HostID: host.ID,
|
|
InstallUUID: installUUID,
|
|
InstallScriptExitCode: ptr.Int(1), // Failed
|
|
InstallScriptOutput: ptr.String("install failed"),
|
|
}
|
|
ctx = hostctx.NewContext(ctx, host)
|
|
err = svc.SaveHostSoftwareInstallResult(ctx, result)
|
|
require.NoError(t, err, "SaveHostSoftwareInstallResult should use primary DB to avoid replication lag")
|
|
|
|
// Verify the attempt_number was set in the primary
|
|
var attemptNumberInWriter *int
|
|
mysqltest.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.GetContext(ctx, q, &attemptNumberInWriter,
|
|
`SELECT attempt_number FROM host_software_installs WHERE execution_id = ?`,
|
|
installUUID)
|
|
})
|
|
require.NotNil(t, attemptNumberInWriter, "attempt_number should be set in primary after result is reported")
|
|
require.Equal(t, 1, *attemptNumberInWriter, "first attempt should be 1")
|
|
|
|
// verify retry was scheduled, and that we did not throw an error because of nil attempt_number
|
|
var retryCount int
|
|
mysqltest.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.GetContext(ctx, q, &retryCount,
|
|
`SELECT COUNT(*) FROM upcoming_activities
|
|
WHERE activity_type = 'software_install'`,
|
|
)
|
|
})
|
|
require.Equal(t, 1, retryCount, "should have scheduled a retry in upcoming_activities")
|
|
}
|
|
|
|
// TestSaveHostSoftwareInstallResultAppOpenSkip verifies that an app-open result on a patch-when-closed
|
|
// policy install is a skip (attempt_number=0, no retry, activity flagged), while an ordinary empty
|
|
// pre_install_query on a non-managed policy still fails, counts, and retries.
|
|
func TestSaveHostSoftwareInstallResultAppOpenSkip(t *testing.T) {
|
|
ds := mysqltest.CreateMySQLDS(t)
|
|
defer ds.Close()
|
|
|
|
opts := &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}, SkipCreateTestUsers: true}
|
|
svc, ctx := newTestService(t, ds, nil, nil, opts)
|
|
|
|
// The test service mocks the activity service, so capture emitted activities by install UUID.
|
|
installedActivities := make(map[string]fleet.ActivityTypeInstalledSoftware)
|
|
opts.ActivityMock.NewActivityFunc = func(_ context.Context, _ *activity_api.User, activity activity_api.ActivityDetails) error {
|
|
if a, ok := activity.(fleet.ActivityTypeInstalledSoftware); ok {
|
|
installedActivities[a.InstallUUID] = a
|
|
}
|
|
return nil
|
|
}
|
|
|
|
user, err := ds.NewUser(ctx, &fleet.User{
|
|
Name: "Admin",
|
|
Password: []byte("p4ssw0rd.123"),
|
|
Email: "admin@example.com",
|
|
GlobalRole: new(fleet.RoleAdmin),
|
|
})
|
|
require.NoError(t, err)
|
|
ctx = viewer.NewContext(ctx, viewer.Viewer{User: user})
|
|
|
|
// patch_when_closed is only valid on a team policy, never global: it forces continuous
|
|
// automations and a title-bound patch policy, both rejected on "All fleets".
|
|
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "patch-when-closed-team"})
|
|
require.NoError(t, err)
|
|
|
|
installerPayload := &fleet.UploadSoftwareInstallerPayload{
|
|
InstallScript: "echo 'installing'",
|
|
Filename: "test_installer.pkg",
|
|
StorageID: uuid.New().String(),
|
|
Title: "Test Software",
|
|
Version: "1.0.0",
|
|
Source: "apps",
|
|
Platform: "darwin",
|
|
UserID: user.ID,
|
|
TeamID: &team.ID,
|
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
|
}
|
|
installerID, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, installerPayload)
|
|
require.NoError(t, err)
|
|
|
|
var titleID uint
|
|
mysqltest.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.GetContext(ctx, q, &titleID,
|
|
`SELECT title_id FROM software_installers WHERE id = ?`, installerID)
|
|
})
|
|
|
|
// createFailingPolicy makes a failing team policy for the host (optionally patch-when-closed) so a
|
|
// retry would be eligible. patch_when_closed isn't settable via the create path yet, so set it directly.
|
|
createFailingPolicy := func(t *testing.T, host *fleet.Host, patchWhenClosed bool) uint {
|
|
policy, err := ds.NewTeamPolicy(ctx, team.ID, &user.ID, fleet.PolicyPayload{
|
|
Name: "policy-" + uuid.NewString(),
|
|
Query: "SELECT 1;",
|
|
})
|
|
require.NoError(t, err)
|
|
if patchWhenClosed {
|
|
mysqltest.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx, `UPDATE policies SET patch_when_closed = 1 WHERE id = ?`, policy.ID)
|
|
return err
|
|
})
|
|
}
|
|
_, err = ds.RecordPolicyQueryExecutions(ctx, host, map[uint]*bool{policy.ID: new(false)}, time.Now(), false, nil)
|
|
require.NoError(t, err)
|
|
return policy.ID
|
|
}
|
|
|
|
// insertPendingInstall queues a pending policy-automation install, returning its execution id.
|
|
insertPendingInstall := func(t *testing.T, host *fleet.Host, policyID uint) string {
|
|
installUUID := uuid.New().String()
|
|
mysqltest.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx, `
|
|
INSERT INTO host_software_installs (
|
|
execution_id, host_id, software_installer_id, policy_id,
|
|
installer_filename, version, software_title_id, software_title_name
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, installUUID, host.ID, installerID, policyID,
|
|
installerPayload.Filename, installerPayload.Version, titleID, installerPayload.Title)
|
|
return err
|
|
})
|
|
return installUUID
|
|
}
|
|
|
|
getAttemptNumber := func(t *testing.T, installUUID string) *int {
|
|
var attempt *int
|
|
mysqltest.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.GetContext(ctx, q, &attempt,
|
|
`SELECT attempt_number FROM host_software_installs WHERE execution_id = ?`, installUUID)
|
|
})
|
|
return attempt
|
|
}
|
|
|
|
countPendingRetries := func(t *testing.T, hostID uint) int {
|
|
var n int
|
|
mysqltest.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.GetContext(ctx, q, &n,
|
|
`SELECT COUNT(*) FROM upcoming_activities WHERE activity_type = 'software_install' AND host_id = ?`, hostID)
|
|
})
|
|
return n
|
|
}
|
|
|
|
t.Run("app open -> skip, no attempt consumed, no retry, activity flagged", func(t *testing.T) {
|
|
host := test.NewHost(t, ds, "skip-host", "10.0.0.1", uuid.NewString(), uuid.NewString(), time.Now())
|
|
require.NoError(t, ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team.ID, []uint{host.ID})))
|
|
installUUID := insertPendingInstall(t, host, createFailingPolicy(t, host, true))
|
|
|
|
result := &fleet.HostSoftwareInstallResultPayload{
|
|
HostID: host.ID,
|
|
InstallUUID: installUUID,
|
|
PreInstallConditionOutput: new(""), // app open
|
|
}
|
|
hctx := hostctx.NewContext(ctx, host)
|
|
require.NoError(t, svc.SaveHostSoftwareInstallResult(hctx, result))
|
|
|
|
attempt := getAttemptNumber(t, installUUID)
|
|
require.NotNil(t, attempt)
|
|
require.Equal(t, 0, *attempt, "skip must not consume a retry attempt")
|
|
|
|
require.Equal(t, 0, countPendingRetries(t, host.ID), "skip must not queue an immediate retry")
|
|
|
|
act, ok := installedActivities[installUUID]
|
|
require.True(t, ok, "an installed_software activity should have been emitted")
|
|
require.Equal(t, string(fleet.SoftwareInstallFailed), act.Status)
|
|
require.True(t, act.InstallSkippedWhenAppOpen, "activity should be flagged as an app-open skip")
|
|
})
|
|
|
|
t.Run("regression: ordinary empty pre_install_query fails, counts, and retries", func(t *testing.T) {
|
|
host := test.NewHost(t, ds, "regress-host", "10.0.0.2", uuid.NewString(), uuid.NewString(), time.Now())
|
|
require.NoError(t, ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team.ID, []uint{host.ID})))
|
|
installUUID := insertPendingInstall(t, host, createFailingPolicy(t, host, false))
|
|
|
|
result := &fleet.HostSoftwareInstallResultPayload{
|
|
HostID: host.ID,
|
|
InstallUUID: installUUID,
|
|
PreInstallConditionOutput: new(""),
|
|
}
|
|
hctx := hostctx.NewContext(ctx, host)
|
|
require.NoError(t, svc.SaveHostSoftwareInstallResult(hctx, result))
|
|
|
|
attempt := getAttemptNumber(t, installUUID)
|
|
require.NotNil(t, attempt)
|
|
require.Equal(t, 1, *attempt, "ordinary pre-install failure must count toward the retry limit")
|
|
|
|
require.Equal(t, 1, countPendingRetries(t, host.ID), "ordinary failure should queue a retry")
|
|
|
|
act, ok := installedActivities[installUUID]
|
|
require.True(t, ok, "an installed_software activity should have been emitted")
|
|
require.Equal(t, string(fleet.SoftwareInstallFailed), act.Status)
|
|
require.False(t, act.InstallSkippedWhenAppOpen, "non-managed failure must not be flagged as a skip")
|
|
})
|
|
|
|
t.Run("many consecutive app-open runs never hit the retry cap", func(t *testing.T) {
|
|
host := test.NewHost(t, ds, "many-runs-host", "10.0.0.3", uuid.NewString(), uuid.NewString(), time.Now())
|
|
require.NoError(t, ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team.ID, []uint{host.ID})))
|
|
policyID := createFailingPolicy(t, host, true)
|
|
|
|
// More consecutive runs than the retry cap; each is a fresh install the app-open query skips.
|
|
for range fleet.MaxPolicyAutomationRetries + 2 {
|
|
installUUID := insertPendingInstall(t, host, policyID)
|
|
hctx := hostctx.NewContext(ctx, host)
|
|
require.NoError(t, svc.SaveHostSoftwareInstallResult(hctx, &fleet.HostSoftwareInstallResultPayload{
|
|
HostID: host.ID,
|
|
InstallUUID: installUUID,
|
|
PreInstallConditionOutput: new(""),
|
|
}))
|
|
attempt := getAttemptNumber(t, installUUID)
|
|
require.NotNil(t, attempt)
|
|
require.Equal(t, 0, *attempt, "every consecutive skip must store attempt_number=0")
|
|
}
|
|
|
|
// The count stays 0, so the cap is never reached and no retries queue.
|
|
count, err := ds.CountHostSoftwareInstallAttempts(ctx, host.ID, installerID, policyID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, 0, count, "skips never accumulate toward the retry cap")
|
|
require.Equal(t, 0, countPendingRetries(t, host.ID), "skips never queue retries")
|
|
})
|
|
}
|
|
|
|
// TestGetOrbitConfigWindowsSetupExperience verifies that GetOrbitConfig sets
|
|
// notifs.RunSetupExperience=true for Windows hosts whose MDM enrollment is
|
|
// in awaiting_configuration Pending or Active, and false otherwise (None,
|
|
// not-enrolled, non-Windows platforms).
|
|
func TestGetOrbitConfigWindowsSetupExperience(t *testing.T) {
|
|
setupSvc := func(t *testing.T) (*mock.Store, fleet.Service, context.Context, *fleet.Host) {
|
|
ds := new(mock.Store)
|
|
license := &fleet.LicenseInfo{Tier: fleet.TierPremium}
|
|
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true})
|
|
|
|
host := &fleet.Host{
|
|
ID: 1,
|
|
OsqueryHostID: ptr.String("test"),
|
|
UUID: "host-uuid-1",
|
|
Platform: "windows",
|
|
}
|
|
|
|
appCfg := &fleet.AppConfig{
|
|
MDM: fleet.MDM{
|
|
EnabledAndConfigured: true,
|
|
WindowsEnabledAndConfigured: true,
|
|
},
|
|
}
|
|
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
|
return appCfg, nil
|
|
}
|
|
ds.GetHostOperatingSystemFunc = func(ctx context.Context, hostID uint) (*fleet.OperatingSystem, error) {
|
|
return &fleet.OperatingSystem{Platform: "windows", Version: "10.0.19045"}, nil
|
|
}
|
|
ds.ListReadyToExecuteScriptsForHostFunc = func(ctx context.Context, hostID uint, onlyShowInternal bool) ([]*fleet.HostScriptResult, error) {
|
|
return nil, nil
|
|
}
|
|
ds.ListReadyToExecuteSoftwareInstallsFunc = func(ctx context.Context, hostID uint) ([]string, error) {
|
|
return nil, nil
|
|
}
|
|
ds.IsHostConnectedToFleetMDMFunc = func(ctx context.Context, h *fleet.Host) (bool, error) {
|
|
return true, nil
|
|
}
|
|
ds.IsHostPendingEscrowFunc = func(ctx context.Context, hostID uint) bool {
|
|
return false
|
|
}
|
|
ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) {
|
|
return &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet, ConnectedToFleet: true}, nil
|
|
}
|
|
ds.GetHostAwaitingConfigurationFunc = func(ctx context.Context, hostUUID string) (bool, error) {
|
|
return false, nil
|
|
}
|
|
// GetOrbitConfig persists the live sync capability on change; default to a no-op so subtests that don't assert it don't nil-panic.
|
|
ds.SetMDMWindowsEnrollmentFleetdSyncCapableFunc = func(ctx context.Context, hostUUID string, capable bool) error {
|
|
return nil
|
|
}
|
|
|
|
ctx = test.HostContext(ctx, host)
|
|
return ds, svc, ctx, host
|
|
}
|
|
|
|
// withWindowsMDMSyncCapability returns a context whose X-Fleet-Capabilities advertise CapabilityWindowsMDMSync, as a Windows fleetd that
|
|
// supports on-demand sync would send on its orbit config request.
|
|
withWindowsMDMSyncCapability := func(ctx context.Context) context.Context {
|
|
req := httptest.NewRequest("POST", "/api/fleet/orbit/config", nil)
|
|
cm := fleet.CapabilityMap{fleet.CapabilityWindowsMDMSync: struct{}{}}
|
|
req.Header.Set(fleet.CapabilitiesHeader, cm.String())
|
|
return capabilities.NewContext(ctx, req)
|
|
}
|
|
|
|
t.Run("Windows host awaiting=Pending sets RunSetupExperience", func(t *testing.T) {
|
|
ds, svc, ctx, _ := setupSvc(t)
|
|
ds.GetMDMWindowsHostConfigStateFunc = func(ctx context.Context, hostUUID string) (*fleet.MDMWindowsHostConfigState, error) {
|
|
return &fleet.MDMWindowsHostConfigState{AwaitingConfiguration: fleet.WindowsMDMAwaitingConfigurationPending}, nil
|
|
}
|
|
|
|
cfg, err := svc.GetOrbitConfig(ctx)
|
|
require.NoError(t, err)
|
|
assert.True(t, cfg.Notifications.RunSetupExperience)
|
|
assert.False(t, cfg.Notifications.WindowsMDMSyncRequest)
|
|
assert.True(t, ds.GetMDMWindowsHostConfigStateFuncInvoked)
|
|
})
|
|
|
|
t.Run("Windows host awaiting=Active sets RunSetupExperience", func(t *testing.T) {
|
|
ds, svc, ctx, _ := setupSvc(t)
|
|
ds.GetMDMWindowsHostConfigStateFunc = func(ctx context.Context, hostUUID string) (*fleet.MDMWindowsHostConfigState, error) {
|
|
return &fleet.MDMWindowsHostConfigState{AwaitingConfiguration: fleet.WindowsMDMAwaitingConfigurationActive}, nil
|
|
}
|
|
|
|
cfg, err := svc.GetOrbitConfig(ctx)
|
|
require.NoError(t, err)
|
|
assert.True(t, cfg.Notifications.RunSetupExperience)
|
|
})
|
|
|
|
t.Run("Windows host awaiting=None, no pending commands sets neither notification", func(t *testing.T) {
|
|
ds, svc, ctx, _ := setupSvc(t)
|
|
ds.GetMDMWindowsHostConfigStateFunc = func(ctx context.Context, hostUUID string) (*fleet.MDMWindowsHostConfigState, error) {
|
|
return &fleet.MDMWindowsHostConfigState{AwaitingConfiguration: fleet.WindowsMDMAwaitingConfigurationNone}, nil
|
|
}
|
|
|
|
cfg, err := svc.GetOrbitConfig(withWindowsMDMSyncCapability(ctx))
|
|
require.NoError(t, err)
|
|
assert.False(t, cfg.Notifications.RunSetupExperience)
|
|
assert.False(t, cfg.Notifications.WindowsMDMSyncRequest)
|
|
})
|
|
|
|
t.Run("Windows host with pending commands and capability sets WindowsMDMSyncRequest", func(t *testing.T) {
|
|
ds, svc, ctx, _ := setupSvc(t)
|
|
ds.GetMDMWindowsHostConfigStateFunc = func(ctx context.Context, hostUUID string) (*fleet.MDMWindowsHostConfigState, error) {
|
|
return &fleet.MDMWindowsHostConfigState{AwaitingConfiguration: fleet.WindowsMDMAwaitingConfigurationNone, HasPendingCommands: true}, nil
|
|
}
|
|
|
|
cfg, err := svc.GetOrbitConfig(withWindowsMDMSyncCapability(ctx))
|
|
require.NoError(t, err)
|
|
assert.True(t, cfg.Notifications.WindowsMDMSyncRequest)
|
|
assert.False(t, cfg.Notifications.RunSetupExperience)
|
|
assert.True(t, ds.GetMDMWindowsHostConfigStateFuncInvoked)
|
|
})
|
|
|
|
t.Run("persists fleetd sync capability only on change", func(t *testing.T) {
|
|
// Capability advertised but the persisted flag is still false -> write it true (so the OMA-DM session can relax the poll).
|
|
ds, svc, ctx, _ := setupSvc(t)
|
|
ds.GetMDMWindowsHostConfigStateFunc = func(ctx context.Context, hostUUID string) (*fleet.MDMWindowsHostConfigState, error) {
|
|
return &fleet.MDMWindowsHostConfigState{AwaitingConfiguration: fleet.WindowsMDMAwaitingConfigurationNone, FleetdSyncCapable: false}, nil
|
|
}
|
|
var wrote *bool
|
|
ds.SetMDMWindowsEnrollmentFleetdSyncCapableFunc = func(ctx context.Context, hostUUID string, capable bool) error {
|
|
wrote = &capable
|
|
return nil
|
|
}
|
|
_, err := svc.GetOrbitConfig(withWindowsMDMSyncCapability(ctx))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, wrote, "should persist the capability when it differs from the stored flag")
|
|
assert.True(t, *wrote)
|
|
|
|
// Capability advertised and the persisted flag already true -> no write.
|
|
ds, svc, ctx, _ = setupSvc(t)
|
|
ds.GetMDMWindowsHostConfigStateFunc = func(ctx context.Context, hostUUID string) (*fleet.MDMWindowsHostConfigState, error) {
|
|
return &fleet.MDMWindowsHostConfigState{AwaitingConfiguration: fleet.WindowsMDMAwaitingConfigurationNone, FleetdSyncCapable: true}, nil
|
|
}
|
|
_, err = svc.GetOrbitConfig(withWindowsMDMSyncCapability(ctx))
|
|
require.NoError(t, err)
|
|
assert.False(t, ds.SetMDMWindowsEnrollmentFleetdSyncCapableFuncInvoked, "no write when the stored flag already matches")
|
|
|
|
// No capability but the persisted flag is true (e.g. fleetd downgrade) -> write it false.
|
|
ds, svc, ctx, _ = setupSvc(t)
|
|
ds.GetMDMWindowsHostConfigStateFunc = func(ctx context.Context, hostUUID string) (*fleet.MDMWindowsHostConfigState, error) {
|
|
return &fleet.MDMWindowsHostConfigState{AwaitingConfiguration: fleet.WindowsMDMAwaitingConfigurationNone, FleetdSyncCapable: true}, nil
|
|
}
|
|
wrote = nil
|
|
ds.SetMDMWindowsEnrollmentFleetdSyncCapableFunc = func(ctx context.Context, hostUUID string, capable bool) error {
|
|
wrote = &capable
|
|
return nil
|
|
}
|
|
_, err = svc.GetOrbitConfig(ctx) // no capability header
|
|
require.NoError(t, err)
|
|
require.NotNil(t, wrote, "should clear the capability when fleetd stops advertising it")
|
|
assert.False(t, *wrote)
|
|
})
|
|
|
|
t.Run("Windows host with pending commands but no capability does not set WindowsMDMSyncRequest", func(t *testing.T) {
|
|
ds, svc, ctx, _ := setupSvc(t)
|
|
ds.GetMDMWindowsHostConfigStateFunc = func(ctx context.Context, hostUUID string) (*fleet.MDMWindowsHostConfigState, error) {
|
|
return &fleet.MDMWindowsHostConfigState{AwaitingConfiguration: fleet.WindowsMDMAwaitingConfigurationNone, HasPendingCommands: true}, nil
|
|
}
|
|
|
|
// ctx has no X-Fleet-Capabilities, as an older fleetd that cannot sync on demand would send.
|
|
cfg, err := svc.GetOrbitConfig(ctx)
|
|
require.NoError(t, err)
|
|
assert.False(t, cfg.Notifications.WindowsMDMSyncRequest)
|
|
})
|
|
|
|
t.Run("Windows host in ESP with pending commands prefers RunSetupExperience over sync", func(t *testing.T) {
|
|
ds, svc, ctx, _ := setupSvc(t)
|
|
ds.GetMDMWindowsHostConfigStateFunc = func(ctx context.Context, hostUUID string) (*fleet.MDMWindowsHostConfigState, error) {
|
|
return &fleet.MDMWindowsHostConfigState{AwaitingConfiguration: fleet.WindowsMDMAwaitingConfigurationPending, HasPendingCommands: true}, nil
|
|
}
|
|
|
|
cfg, err := svc.GetOrbitConfig(withWindowsMDMSyncCapability(ctx))
|
|
require.NoError(t, err)
|
|
assert.True(t, cfg.Notifications.RunSetupExperience)
|
|
assert.False(t, cfg.Notifications.WindowsMDMSyncRequest)
|
|
})
|
|
|
|
t.Run("Windows host not enrolled (NotFound) sets neither notification", func(t *testing.T) {
|
|
ds, svc, ctx, _ := setupSvc(t)
|
|
ds.GetMDMWindowsHostConfigStateFunc = func(ctx context.Context, hostUUID string) (*fleet.MDMWindowsHostConfigState, error) {
|
|
return nil, &orbitTestNotFoundErr{}
|
|
}
|
|
|
|
cfg, err := svc.GetOrbitConfig(withWindowsMDMSyncCapability(ctx))
|
|
require.NoError(t, err)
|
|
assert.False(t, cfg.Notifications.RunSetupExperience)
|
|
assert.False(t, cfg.Notifications.WindowsMDMSyncRequest)
|
|
})
|
|
|
|
t.Run("Windows host with non-NotFound lookup error returns the error", func(t *testing.T) {
|
|
ds, svc, ctx, _ := setupSvc(t)
|
|
ds.GetMDMWindowsHostConfigStateFunc = func(ctx context.Context, hostUUID string) (*fleet.MDMWindowsHostConfigState, error) {
|
|
return nil, errors.New("transient db error")
|
|
}
|
|
|
|
_, err := svc.GetOrbitConfig(ctx)
|
|
require.Error(t, err)
|
|
})
|
|
|
|
t.Run("non-Windows host does not query Windows host config state", func(t *testing.T) {
|
|
ds, svc, ctx, host := setupSvc(t)
|
|
host.Platform = "darwin"
|
|
|
|
cfg, err := svc.GetOrbitConfig(ctx)
|
|
require.NoError(t, err)
|
|
assert.False(t, cfg.Notifications.RunSetupExperience)
|
|
assert.False(t, cfg.Notifications.WindowsMDMSyncRequest)
|
|
assert.False(t, ds.GetMDMWindowsHostConfigStateFuncInvoked,
|
|
"non-Windows hosts must not invoke the Windows lookup")
|
|
})
|
|
}
|
|
|
|
// orbitTestNotFoundErr is a minimal IsNotFound error type for orbit config tests.
|
|
type orbitTestNotFoundErr struct{}
|
|
|
|
func (e *orbitTestNotFoundErr) Error() string { return "not found" }
|
|
func (e *orbitTestNotFoundErr) IsNotFound() bool { return true }
|
|
|
|
func rawJSON(s string) *json.RawMessage {
|
|
r := json.RawMessage(s)
|
|
return &r
|
|
}
|
|
|
|
func TestMaybeStampOrbitDebugFromAgentOptions(t *testing.T) {
|
|
getInternal := func(svc fleet.Service) *Service {
|
|
return ((svc.(validationMiddleware)).Service).(*Service)
|
|
}
|
|
|
|
t.Run("no agent options -> no stamp", func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{SkipCreateTestUsers: true})
|
|
host := &fleet.Host{ID: 1}
|
|
appCfg := &fleet.AppConfig{}
|
|
|
|
err := getInternal(svc).maybeStampOrbitDebugFromAgentOptions(ctx, host, appCfg)
|
|
require.NoError(t, err)
|
|
require.False(t, ds.ExtendHostOrbitDebugUntilFuncInvoked)
|
|
})
|
|
|
|
t.Run("zero duration -> no stamp", func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{SkipCreateTestUsers: true})
|
|
host := &fleet.Host{ID: 1}
|
|
appCfg := &fleet.AppConfig{
|
|
AgentOptions: rawJSON(`{"orbit": {"debug_logging_on_enroll_duration": 0}}`),
|
|
}
|
|
|
|
err := getInternal(svc).maybeStampOrbitDebugFromAgentOptions(ctx, host, appCfg)
|
|
require.NoError(t, err)
|
|
require.False(t, ds.ExtendHostOrbitDebugUntilFuncInvoked)
|
|
})
|
|
|
|
t.Run("global option set, no team -> stamps from app config", func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{SkipCreateTestUsers: true})
|
|
host := &fleet.Host{ID: 42}
|
|
appCfg := &fleet.AppConfig{
|
|
AgentOptions: rawJSON(`{"orbit": {"debug_logging_on_enroll_duration": 3600}}`),
|
|
}
|
|
|
|
var gotID uint
|
|
var gotUntil time.Time
|
|
ds.ExtendHostOrbitDebugUntilFunc = func(ctx context.Context, hostID uint, until time.Time) error {
|
|
gotID = hostID
|
|
gotUntil = until
|
|
return nil
|
|
}
|
|
|
|
before := time.Now()
|
|
err := getInternal(svc).maybeStampOrbitDebugFromAgentOptions(ctx, host, appCfg)
|
|
require.NoError(t, err)
|
|
require.True(t, ds.ExtendHostOrbitDebugUntilFuncInvoked)
|
|
require.Equal(t, host.ID, gotID)
|
|
require.WithinDuration(t, before.Add(time.Hour), gotUntil, time.Minute)
|
|
})
|
|
|
|
t.Run("team option set -> stamps from team agent options, ignores global", func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{SkipCreateTestUsers: true})
|
|
teamID := uint(7)
|
|
host := &fleet.Host{ID: 99, TeamID: &teamID}
|
|
appCfg := &fleet.AppConfig{
|
|
// Team membership: team options win, global is ignored.
|
|
AgentOptions: rawJSON(`{"orbit": {"debug_logging_on_enroll_duration": 86400}}`),
|
|
}
|
|
|
|
ds.TeamAgentOptionsFunc = func(ctx context.Context, id uint) (*json.RawMessage, error) {
|
|
require.Equal(t, teamID, id)
|
|
return rawJSON(`{"orbit": {"debug_logging_on_enroll_duration": 1800}}`), nil
|
|
}
|
|
var gotUntil time.Time
|
|
ds.ExtendHostOrbitDebugUntilFunc = func(ctx context.Context, hostID uint, until time.Time) error {
|
|
gotUntil = until
|
|
return nil
|
|
}
|
|
|
|
before := time.Now()
|
|
err := getInternal(svc).maybeStampOrbitDebugFromAgentOptions(ctx, host, appCfg)
|
|
require.NoError(t, err)
|
|
require.True(t, ds.TeamAgentOptionsFuncInvoked)
|
|
require.True(t, ds.ExtendHostOrbitDebugUntilFuncInvoked)
|
|
require.WithinDuration(t, before.Add(30*time.Minute), gotUntil, time.Minute)
|
|
})
|
|
|
|
t.Run("team has no agent options row -> no stamp, no fallback to global", func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{SkipCreateTestUsers: true})
|
|
teamID := uint(7)
|
|
host := &fleet.Host{ID: 99, TeamID: &teamID}
|
|
appCfg := &fleet.AppConfig{
|
|
AgentOptions: rawJSON(`{"orbit": {"debug_logging_on_enroll_duration": 3600}}`),
|
|
}
|
|
ds.TeamAgentOptionsFunc = func(ctx context.Context, id uint) (*json.RawMessage, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
err := getInternal(svc).maybeStampOrbitDebugFromAgentOptions(ctx, host, appCfg)
|
|
require.NoError(t, err)
|
|
require.True(t, ds.TeamAgentOptionsFuncInvoked)
|
|
require.False(t, ds.ExtendHostOrbitDebugUntilFuncInvoked)
|
|
})
|
|
|
|
t.Run("over-cap value defensively clamped at 24h", func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{SkipCreateTestUsers: true})
|
|
host := &fleet.Host{ID: 1}
|
|
// Bypass the validator by stuffing a too-large value directly.
|
|
appCfg := &fleet.AppConfig{
|
|
AgentOptions: rawJSON(`{"orbit": {"debug_logging_on_enroll_duration": 360000}}`),
|
|
}
|
|
var gotUntil time.Time
|
|
ds.ExtendHostOrbitDebugUntilFunc = func(ctx context.Context, hostID uint, until time.Time) error {
|
|
gotUntil = until
|
|
return nil
|
|
}
|
|
|
|
before := time.Now()
|
|
err := getInternal(svc).maybeStampOrbitDebugFromAgentOptions(ctx, host, appCfg)
|
|
require.NoError(t, err)
|
|
require.WithinDuration(t, before.Add(fleet.MaxOrbitDebugLoggingOnEnrollDuration), gotUntil, time.Minute)
|
|
})
|
|
}
|
|
|
|
func TestResolveOrbitDebugLogging(t *testing.T) {
|
|
ctx := t.Context()
|
|
future := time.Now().Add(time.Hour)
|
|
past := time.Now().Add(-time.Hour)
|
|
|
|
cases := []struct {
|
|
name string
|
|
host *fleet.Host
|
|
inFlags json.RawMessage
|
|
wantDebug *bool
|
|
wantFlags map[string]any
|
|
}{
|
|
{
|
|
name: "no host -> nil debug, flags unchanged",
|
|
host: nil,
|
|
inFlags: nil,
|
|
wantDebug: nil,
|
|
},
|
|
{
|
|
name: "no override -> nil debug, flags unchanged",
|
|
host: &fleet.Host{},
|
|
inFlags: json.RawMessage(`{"distributed_interval":10}`),
|
|
wantDebug: nil,
|
|
},
|
|
{
|
|
name: "unexpired override -> debug on, flags merged",
|
|
host: &fleet.Host{OrbitDebugUntil: &future},
|
|
inFlags: nil,
|
|
wantDebug: new(true),
|
|
wantFlags: map[string]any{
|
|
"verbose": true,
|
|
},
|
|
},
|
|
{
|
|
name: "unexpired override with admin flags -> merged",
|
|
host: &fleet.Host{OrbitDebugUntil: &future},
|
|
inFlags: json.RawMessage(`{"distributed_interval":10}`),
|
|
wantDebug: new(true),
|
|
wantFlags: map[string]any{
|
|
"distributed_interval": float64(10),
|
|
"verbose": true,
|
|
},
|
|
},
|
|
{
|
|
name: "admin verbose:false wins over debug-on",
|
|
host: &fleet.Host{OrbitDebugUntil: &future},
|
|
inFlags: json.RawMessage(`{"verbose":false}`),
|
|
wantDebug: new(true),
|
|
wantFlags: map[string]any{
|
|
"verbose": false,
|
|
},
|
|
},
|
|
{
|
|
name: "expired override is ignored",
|
|
host: &fleet.Host{OrbitDebugUntil: &past},
|
|
inFlags: nil,
|
|
wantDebug: nil,
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
gotFlags, gotDebug, err := resolveOrbitDebugLogging(ctx, tc.host, tc.inFlags)
|
|
require.NoError(t, err)
|
|
|
|
if tc.wantDebug == nil {
|
|
require.Nil(t, gotDebug)
|
|
require.Equal(t, tc.inFlags, gotFlags)
|
|
return
|
|
}
|
|
|
|
require.NotNil(t, gotDebug)
|
|
require.Equal(t, *tc.wantDebug, *gotDebug)
|
|
var got map[string]any
|
|
require.NoError(t, json.Unmarshal(gotFlags, &got))
|
|
require.Equal(t, tc.wantFlags, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGetOrbitConfigWindowsManagedLocalAccount covers the CreateWindowsManagedLocalAccount notification gating: it is
|
|
// set for any Windows MDM host (not just during the setup experience) when the team or No-team setting is enabled,
|
|
// fleetd advertises the capability, and it stops once the host has escrowed a password for its current enrollment.
|
|
func TestGetOrbitConfigWindowsManagedLocalAccount(t *testing.T) {
|
|
// withMLACapability returns a context whose X-Fleet-Capabilities advertise the managed local
|
|
// account capability, as a capable Windows fleetd would send.
|
|
withMLACapability := func(ctx context.Context) context.Context {
|
|
req := httptest.NewRequest("POST", "/api/fleet/orbit/config", nil)
|
|
cm := fleet.CapabilityMap{fleet.CapabilityWindowsManagedLocalAccount: struct{}{}}
|
|
req.Header.Set(fleet.CapabilitiesHeader, cm.String())
|
|
return capabilities.NewContext(ctx, req)
|
|
}
|
|
|
|
setupSvc := func(t *testing.T, tier string, settingEnabled bool, awaiting fleet.WindowsMDMAwaitingConfiguration,
|
|
alreadyEscrowed bool,
|
|
) (*mock.Store, fleet.Service, context.Context) {
|
|
ds := new(mock.Store)
|
|
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: &fleet.LicenseInfo{Tier: tier}, SkipCreateTestUsers: true})
|
|
|
|
host := &fleet.Host{ID: 1, OsqueryHostID: new("test"), UUID: "host-uuid-1", Platform: "windows"}
|
|
appCfg := &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true, WindowsEnabledAndConfigured: true}}
|
|
appCfg.MDM.WindowsSettings.ManagedLocalAccountSettings.Enabled = optjson.SetBool(settingEnabled)
|
|
|
|
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { return appCfg, nil }
|
|
ds.ListReadyToExecuteScriptsForHostFunc = func(ctx context.Context, hostID uint, onlyShowInternal bool) ([]*fleet.HostScriptResult, error) {
|
|
return nil, nil
|
|
}
|
|
ds.ListReadyToExecuteSoftwareInstallsFunc = func(ctx context.Context, hostID uint) ([]string, error) { return nil, nil }
|
|
ds.IsHostConnectedToFleetMDMFunc = func(ctx context.Context, h *fleet.Host) (bool, error) { return true, nil }
|
|
ds.IsHostPendingEscrowFunc = func(ctx context.Context, hostID uint) bool { return false }
|
|
ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) {
|
|
return &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet, ConnectedToFleet: true}, nil
|
|
}
|
|
ds.SetMDMWindowsEnrollmentFleetdSyncCapableFunc = func(ctx context.Context, hostUUID string, capable bool) error { return nil }
|
|
ds.GetMDMWindowsHostConfigStateFunc = func(ctx context.Context, hostUUID string) (*fleet.MDMWindowsHostConfigState, error) {
|
|
return &fleet.MDMWindowsHostConfigState{
|
|
AwaitingConfiguration: awaiting,
|
|
ManagedLocalAccountEscrowed: alreadyEscrowed,
|
|
}, nil
|
|
}
|
|
|
|
ctx = test.HostContext(ctx, host)
|
|
return ds, svc, ctx
|
|
}
|
|
|
|
// Enabling the setting provisions the whole fleet: a host long past its ESP is asked to create the account just like
|
|
// one that just enrolled. This guards against the notification being re-scoped to the ESP.
|
|
t.Run("set regardless of setup experience state", func(t *testing.T) {
|
|
for _, awaiting := range []fleet.WindowsMDMAwaitingConfiguration{
|
|
fleet.WindowsMDMAwaitingConfigurationPending,
|
|
fleet.WindowsMDMAwaitingConfigurationActive,
|
|
fleet.WindowsMDMAwaitingConfigurationNone,
|
|
} {
|
|
_, svc, ctx := setupSvc(t, fleet.TierPremium, true, awaiting, false)
|
|
cfg, err := svc.GetOrbitConfig(withMLACapability(ctx))
|
|
require.NoError(t, err)
|
|
assert.True(t, cfg.Notifications.CreateWindowsManagedLocalAccount, "awaiting_configuration=%v", awaiting)
|
|
}
|
|
})
|
|
|
|
// Idempotence: a host that already escrowed for this enrollment is left alone, so the account is not recreated and
|
|
// the created activity is not logged on every poll.
|
|
t.Run("already escrowed for this enrollment does not set it", func(t *testing.T) {
|
|
_, svc, ctx := setupSvc(t, fleet.TierPremium, true, fleet.WindowsMDMAwaitingConfigurationNone, true)
|
|
cfg, err := svc.GetOrbitConfig(withMLACapability(ctx))
|
|
require.NoError(t, err)
|
|
assert.False(t, cfg.Notifications.CreateWindowsManagedLocalAccount)
|
|
})
|
|
|
|
t.Run("setting disabled does not set it", func(t *testing.T) {
|
|
_, svc, ctx := setupSvc(t, fleet.TierPremium, false, fleet.WindowsMDMAwaitingConfigurationPending, false)
|
|
cfg, err := svc.GetOrbitConfig(withMLACapability(ctx))
|
|
require.NoError(t, err)
|
|
assert.False(t, cfg.Notifications.CreateWindowsManagedLocalAccount)
|
|
})
|
|
|
|
t.Run("missing capability does not set it", func(t *testing.T) {
|
|
_, svc, ctx := setupSvc(t, fleet.TierPremium, true, fleet.WindowsMDMAwaitingConfigurationPending, false)
|
|
// no capability header on the context
|
|
cfg, err := svc.GetOrbitConfig(ctx)
|
|
require.NoError(t, err)
|
|
assert.False(t, cfg.Notifications.CreateWindowsManagedLocalAccount)
|
|
})
|
|
|
|
t.Run("free license does not set it", func(t *testing.T) {
|
|
_, svc, ctx := setupSvc(t, fleet.TierFree, true, fleet.WindowsMDMAwaitingConfigurationPending, false)
|
|
cfg, err := svc.GetOrbitConfig(withMLACapability(ctx))
|
|
require.NoError(t, err)
|
|
assert.False(t, cfg.Notifications.CreateWindowsManagedLocalAccount)
|
|
})
|
|
}
|
|
|
|
// TestEscrowWindowsManagedLocalAccountPassword covers the orbit escrow endpoint: eligibility via Windows MDM enrollment,
|
|
// input validation, the created activity, and that an escrow is stored even when the setting was toggled off after the
|
|
// notification (never orphan the on-device account).
|
|
func TestEscrowWindowsManagedLocalAccountPassword(t *testing.T) {
|
|
setup := func(t *testing.T, enrolled bool, settingEnabled bool) (*mock.Store, fleet.Service, context.Context, *TestServerOpts) {
|
|
ds := new(mock.Store)
|
|
opts := &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}, SkipCreateTestUsers: true}
|
|
svc, ctx := newTestService(t, ds, nil, nil, opts)
|
|
host := &fleet.Host{ID: 1, UUID: "host-uuid-1", OsqueryHostID: new("test")}
|
|
ctx = test.HostContext(ctx, host)
|
|
|
|
ds.MDMWindowsGetEnrolledDeviceWithHostUUIDFunc = func(ctx context.Context, hostUUID string) (*fleet.MDMWindowsEnrolledDevice, error) {
|
|
if !enrolled {
|
|
return nil, newNotFoundError()
|
|
}
|
|
return &fleet.MDMWindowsEnrolledDevice{HostUUID: hostUUID}, nil
|
|
}
|
|
appCfg := &fleet.AppConfig{MDM: fleet.MDM{WindowsEnabledAndConfigured: true}}
|
|
appCfg.MDM.WindowsSettings.ManagedLocalAccountSettings.Enabled = optjson.SetBool(settingEnabled)
|
|
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { return appCfg, nil }
|
|
ds.SaveHostManagedLocalAccountFromEscrowFunc = func(ctx context.Context, hostUUID, plaintextPassword string) error { return nil }
|
|
ds.ReportManagedLocalAccountEscrowErrorFunc = func(ctx context.Context, hostUUID, clientError string) error { return nil }
|
|
ds.SetMDMWindowsManagedLocalAccountEscrowedFunc = func(ctx context.Context, hostUUID string, escrowed bool) (bool, error) {
|
|
return escrowed, nil
|
|
}
|
|
return ds, svc, ctx, opts
|
|
}
|
|
|
|
t.Run("host without Windows MDM enrollment is rejected", func(t *testing.T) {
|
|
ds, svc, ctx, _ := setup(t, false, true)
|
|
err := svc.EscrowWindowsManagedLocalAccountPassword(ctx, "pw", "")
|
|
require.Error(t, err)
|
|
var badReq *fleet.BadRequestError
|
|
require.ErrorAs(t, err, &badReq)
|
|
require.False(t, ds.SaveHostManagedLocalAccountFromEscrowFuncInvoked)
|
|
})
|
|
|
|
t.Run("invalid password is rejected", func(t *testing.T) {
|
|
for name, password := range map[string]string{
|
|
"empty": "",
|
|
"too long": strings.Repeat("a", managedLocalAccountMaxPasswordLength+1),
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
ds, svc, ctx, _ := setup(t, true, true)
|
|
err := svc.EscrowWindowsManagedLocalAccountPassword(ctx, password, "")
|
|
require.Error(t, err)
|
|
require.False(t, ds.SaveHostManagedLocalAccountFromEscrowFuncInvoked)
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("client error is recorded and no password is stored", func(t *testing.T) {
|
|
ds, svc, ctx, opts := setup(t, true, true)
|
|
activityLogged := false
|
|
opts.ActivityMock.NewActivityFunc = func(_ context.Context, _ *activity_api.User, _ activity_api.ActivityDetails) error {
|
|
activityLogged = true
|
|
return nil
|
|
}
|
|
var reportedError string
|
|
ds.ReportManagedLocalAccountEscrowErrorFunc = func(ctx context.Context, hostUUID, clientError string) error {
|
|
reportedError = clientError
|
|
return nil
|
|
}
|
|
var escrowedFlag bool
|
|
ds.SetMDMWindowsManagedLocalAccountEscrowedFunc = func(ctx context.Context, hostUUID string, escrowed bool) (bool, error) {
|
|
escrowedFlag = escrowed
|
|
return true, nil
|
|
}
|
|
err := svc.EscrowWindowsManagedLocalAccountPassword(ctx, "", "netapi32 add failed")
|
|
require.NoError(t, err)
|
|
require.True(t, ds.ReportManagedLocalAccountEscrowErrorFuncInvoked)
|
|
require.Equal(t, "netapi32 add failed", reportedError)
|
|
require.False(t, ds.SaveHostManagedLocalAccountFromEscrowFuncInvoked)
|
|
require.False(t, activityLogged)
|
|
// The flag is cleared so the host keeps being asked and a transient failure self-heals.
|
|
require.True(t, ds.SetMDMWindowsManagedLocalAccountEscrowedFuncInvoked)
|
|
require.False(t, escrowedFlag)
|
|
})
|
|
|
|
t.Run("client error is truncated by rune to fit the column", func(t *testing.T) {
|
|
ds, svc, ctx, _ := setup(t, true, true)
|
|
var reportedError string
|
|
ds.ReportManagedLocalAccountEscrowErrorFunc = func(ctx context.Context, hostUUID, clientError string) error {
|
|
reportedError = clientError
|
|
return nil
|
|
}
|
|
// Multi-byte runes so a byte-wise truncation would produce invalid UTF-8.
|
|
err := svc.EscrowWindowsManagedLocalAccountPassword(ctx, "", strings.Repeat("é", 400))
|
|
require.NoError(t, err)
|
|
require.Equal(t, 255, utf8.RuneCountInString(reportedError))
|
|
require.True(t, utf8.ValidString(reportedError))
|
|
})
|
|
|
|
t.Run("successful escrow stores the password and logs the created activity once", func(t *testing.T) {
|
|
ds, svc, ctx, opts := setup(t, true, true)
|
|
var savedPassword string
|
|
ds.SaveHostManagedLocalAccountFromEscrowFunc = func(ctx context.Context, hostUUID, plaintextPassword string) error {
|
|
savedPassword = plaintextPassword
|
|
return nil
|
|
}
|
|
activityCount := 0
|
|
opts.ActivityMock.NewActivityFunc = func(_ context.Context, _ *activity_api.User, a activity_api.ActivityDetails) error {
|
|
require.Equal(t, fleet.ActivityTypeCreatedManagedLocalAccount{}.ActivityName(), a.ActivityName())
|
|
activityCount++
|
|
return nil
|
|
}
|
|
var escrowedFlag bool
|
|
ds.SetMDMWindowsManagedLocalAccountEscrowedFunc = func(ctx context.Context, hostUUID string, escrowed bool) (bool, error) {
|
|
escrowedFlag = escrowed
|
|
return true, nil
|
|
}
|
|
err := svc.EscrowWindowsManagedLocalAccountPassword(ctx, "device-generated-pw", "")
|
|
require.NoError(t, err)
|
|
require.True(t, ds.SaveHostManagedLocalAccountFromEscrowFuncInvoked)
|
|
require.Equal(t, "device-generated-pw", savedPassword)
|
|
require.Equal(t, 1, activityCount)
|
|
// Marking the enrollment provisioned is what stops the host being asked again.
|
|
require.True(t, escrowedFlag)
|
|
})
|
|
|
|
// A device that re-sends an escrow it already made stores the password again but must not claim a
|
|
// second account, mirroring how BitLocker only logs when the key was actually archived.
|
|
t.Run("re-sent escrow stores the password but does not log the activity again", func(t *testing.T) {
|
|
ds, svc, ctx, opts := setup(t, true, true)
|
|
activityCount := 0
|
|
opts.ActivityMock.NewActivityFunc = func(_ context.Context, _ *activity_api.User, _ activity_api.ActivityDetails) error {
|
|
activityCount++
|
|
return nil
|
|
}
|
|
// The enrollment is already marked provisioned, so the flag does not change.
|
|
ds.SetMDMWindowsManagedLocalAccountEscrowedFunc = func(ctx context.Context, hostUUID string, escrowed bool) (bool, error) {
|
|
return false, nil
|
|
}
|
|
err := svc.EscrowWindowsManagedLocalAccountPassword(ctx, "device-generated-pw", "")
|
|
require.NoError(t, err)
|
|
require.True(t, ds.SaveHostManagedLocalAccountFromEscrowFuncInvoked)
|
|
require.Zero(t, activityCount)
|
|
})
|
|
|
|
t.Run("stores the password even when the setting was disabled after the notification", func(t *testing.T) {
|
|
ds, svc, ctx, opts := setup(t, true, false)
|
|
opts.ActivityMock.NewActivityFunc = func(_ context.Context, _ *activity_api.User, _ activity_api.ActivityDetails) error { return nil }
|
|
err := svc.EscrowWindowsManagedLocalAccountPassword(ctx, "device-generated-pw", "")
|
|
require.NoError(t, err)
|
|
require.True(t, ds.SaveHostManagedLocalAccountFromEscrowFuncInvoked)
|
|
})
|
|
|
|
// A failed save must surface as an error rather than a silent success, and must not mark the enrollment provisioned.
|
|
t.Run("failed save is reported and leaves the host to be asked again", func(t *testing.T) {
|
|
ds, svc, ctx, _ := setup(t, true, true)
|
|
ds.SaveHostManagedLocalAccountFromEscrowFunc = func(ctx context.Context, hostUUID, plaintextPassword string) error {
|
|
return errors.New("transient db failure")
|
|
}
|
|
err := svc.EscrowWindowsManagedLocalAccountPassword(ctx, "device-generated-pw", "")
|
|
require.Error(t, err)
|
|
require.False(t, ds.SetMDMWindowsManagedLocalAccountEscrowedFuncInvoked)
|
|
})
|
|
|
|
// The setting check only decides whether to warn, so a failure to read it must never cost the
|
|
// password: the account already exists on the device and this is the only chance to record it. (The fallback is re-creating the account.)
|
|
t.Run("stores the password even when the setting cannot be read", func(t *testing.T) {
|
|
ds, svc, ctx, opts := setup(t, true, true)
|
|
opts.ActivityMock.NewActivityFunc = func(_ context.Context, _ *activity_api.User, _ activity_api.ActivityDetails) error { return nil }
|
|
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { return nil, errors.New("transient db failure") }
|
|
err := svc.EscrowWindowsManagedLocalAccountPassword(ctx, "device-generated-pw", "")
|
|
require.NoError(t, err)
|
|
require.True(t, ds.SaveHostManagedLocalAccountFromEscrowFuncInvoked)
|
|
})
|
|
}
|
|
|
|
func TestEnrollOrbitEndUserAuthBypass(t *testing.T) {
|
|
// When end user authentication is required and the enrolling agent does not
|
|
// advertise the end_user_auth capability (for example an older agent that
|
|
// does not set the X-Fleet-Capabilities header), the
|
|
// AllowOrbitEndUserAuthBypass config flag decides whether enrollment is
|
|
// blocked or allowed.
|
|
newSvc := func(t *testing.T, allowBypass bool) (*mock.DataStore, fleet.Service, context.Context) {
|
|
// mock.Store hard-codes EnrollOrbit to return (nil, nil), which would make
|
|
// the bypass-allowed success path panic. Use the underlying mock.DataStore so
|
|
// EnrollOrbitFunc is honored.
|
|
ds := new(mock.DataStore)
|
|
cfg := config.TestConfig()
|
|
cfg.MDM.AllowOrbitEndUserAuthBypass = allowBypass
|
|
svc, ctx := newTestServiceWithConfig(t, ds, cfg, nil, nil)
|
|
|
|
// Global enroll secret (no team) with end user auth required at the app-config level.
|
|
ds.VerifyEnrollSecretFunc = func(ctx context.Context, secret string) (*fleet.EnrollSecret, error) {
|
|
return &fleet.EnrollSecret{Secret: secret}, nil
|
|
}
|
|
ds.GetHostIdentityCertByNameFunc = func(ctx context.Context, name string) (*hostidentity_types.HostIdentityCertificate, error) {
|
|
return nil, nil
|
|
}
|
|
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
|
ac := &fleet.AppConfig{}
|
|
ac.MDM.EnabledAndConfigured = true
|
|
ac.MDM.MacOSSetup.EnableEndUserAuthentication = true
|
|
return ac, nil
|
|
}
|
|
// No IdP account linked and not previously enrolled: a genuine first-time enrollment.
|
|
ds.GetMDMIdPAccountByHostUUIDFunc = func(ctx context.Context, hostUUID string) (*fleet.MDMIdPAccount, error) {
|
|
return nil, nil
|
|
}
|
|
ds.HostPreviouslyOrbitEnrolledFunc = func(ctx context.Context, hostInfo fleet.OrbitHostInfo, isMDMEnabled bool) (bool, error) {
|
|
return false, nil
|
|
}
|
|
ds.EnrollOrbitFunc = func(ctx context.Context, opts ...fleet.DatastoreEnrollOrbitOption) (*fleet.Host, error) {
|
|
return &fleet.Host{ID: 1, UUID: "host-uuid-1", Platform: "ubuntu"}, nil
|
|
}
|
|
ds.MaybeAssociateHostWithScimUserFunc = func(ctx context.Context, hostID uint) error {
|
|
return nil
|
|
}
|
|
return ds, svc, ctx
|
|
}
|
|
|
|
hostInfo := fleet.OrbitHostInfo{
|
|
HardwareUUID: "host-uuid-1",
|
|
HardwareSerial: "serial-1",
|
|
Hostname: "host-1",
|
|
Platform: "ubuntu",
|
|
PlatformLike: "debian",
|
|
}
|
|
|
|
// noEUACtx builds a request context advertising only unrelated capabilities,
|
|
// simulating an agent that does not support end user auth.
|
|
noEUACtx := func(ctx context.Context) context.Context {
|
|
req := httptest.NewRequest("POST", "/api/fleet/orbit/enroll", nil)
|
|
req.Header.Set(fleet.CapabilitiesHeader, "foo,bar")
|
|
return capabilities.NewContext(ctx, req)
|
|
}
|
|
|
|
t.Run("flag disabled blocks enrollment", func(t *testing.T) {
|
|
ds, svc, ctx := newSvc(t, false)
|
|
_, err := svc.EnrollOrbit(noEUACtx(ctx), hostInfo, "secret", "")
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "END_USER_AUTH_REQUIRED")
|
|
require.False(t, ds.EnrollOrbitFuncInvoked, "no host must be enrolled when EUA is required and the flag is off")
|
|
})
|
|
|
|
t.Run("flag enabled allows enrollment", func(t *testing.T) {
|
|
ds, svc, ctx := newSvc(t, true)
|
|
nodeKey, err := svc.EnrollOrbit(noEUACtx(ctx), hostInfo, "secret", "")
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, nodeKey)
|
|
require.True(t, ds.EnrollOrbitFuncInvoked)
|
|
})
|
|
|
|
t.Run("flag enabled still gates agents that support EUA", func(t *testing.T) {
|
|
// The escape hatch only applies to agents that do not support end user
|
|
// auth. A modern agent that advertises the capability must still go
|
|
// through the SSO flow even when the flag is on.
|
|
ds, svc, ctx := newSvc(t, true)
|
|
euaCtx := func(ctx context.Context) context.Context {
|
|
req := httptest.NewRequest("POST", "/api/fleet/orbit/enroll", nil)
|
|
req.Header.Set(fleet.CapabilitiesHeader, string(fleet.CapabilityEndUserAuth))
|
|
return capabilities.NewContext(ctx, req)
|
|
}
|
|
_, err := svc.EnrollOrbit(euaCtx(ctx), hostInfo, "secret", "")
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "END_USER_AUTH_REQUIRED")
|
|
require.False(t, ds.EnrollOrbitFuncInvoked)
|
|
})
|
|
|
|
t.Run("windows EUA token takes precedence over the flag", func(t *testing.T) {
|
|
// A Windows host presenting an EUA token must go through the token path even when
|
|
// the flag is on and the client omits the capability — the token case is ordered
|
|
// first. wstepCertManager is unset in this harness, so the token path falls back to
|
|
// END_USER_AUTH_REQUIRED; the point is that the flag's bypass does not fire (no host
|
|
// is enrolled), proving the token case wins.
|
|
ds, svc, ctx := newSvc(t, true)
|
|
winHost := hostInfo
|
|
winHost.Platform = "windows"
|
|
winHost.PlatformLike = ""
|
|
_, err := svc.EnrollOrbit(noEUACtx(ctx), winHost, "secret", "some-eua-token")
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "END_USER_AUTH_REQUIRED")
|
|
require.False(t, ds.EnrollOrbitFuncInvoked, "the flag bypass must not fire when an EUA token is present")
|
|
})
|
|
}
|