<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #43859 This PR brings the Windows Autopilot setup experience to parity with macOS DEP. Windows hosts that enroll through Autopilot now coordinate with Fleet during the OOBE Enrollment Status Page (ESP), so admin-defined software installs run while the device is still waiting at the ESP screen, before the user can sign in. Fleet holds the device on the ESP until profiles and setup-experience software all reach a terminal state, then either releases the device to login or blocks it on a Reset PC failure screen. A new team-level setting controls the policy: when enabled, any critical software install failure during ESP blocks the device with a software-specific error message; when disabled, the device releases regardless of install outcomes (best effort). A pure 3-hour timeout also forces a finalize, with a timeout-specific error message on the block screen. The setting is premium-only and rejected when Windows MDM is not configured. Beyond the gating itself, the PR adds the supporting machinery: orbit-driven setup-experience initialization on Windows so installs are enqueued at the right moment, defense-in-depth cancellation of pending software installs (both queue rows and status rows) whenever the device is going to block or time out, idempotent re-enrollment cleanup so a device that resets and re-enrolls during ESP starts from a clean state. Internally, finalize is structured so a transient failure at any step (cancel, persist, or the state-machine transition) leaves the device retriable on the next management session rather than permanently stuck on "Working on it...". The behavior is exercised by example-based tests, a property-based test that randomly samples the wait/block/release decision matrix, and manual VM testing across Autopilot edge cases. <img width="1184" height="776" alt="image" src="https://github.com/user-attachments/assets/5e48660d-235d-40bd-80b6-f8591c579279" /> # Checklist for submitter - [x] Timeouts are implemented and retries are limited to avoid infinite loops ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Re-enrollment now clears stale setup-experience results and pending activities so devices aren’t blocked by old work. * Insert operations tolerate missing enrollments and return clear not-found behavior. * **New Features** * ESP finalization waits for software installation results and can block or release based on configurable “require all” behavior; blocking cancels pending steps and shows prioritized error text. * Finalization persists batched final commands for consistent retries. * Orbit config exposes setup-experience notification for pending/active Windows hosts. * **Tests** * Expanded coverage for ESP flows, datastore awaiting-configuration, Orbit config, and re-enrollment cascades. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Konstantin Sykulev <konst@sykulev.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1213 lines
42 KiB
Go
1213 lines
42 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/fleetdm/fleet/v4/pkg/optjson"
|
|
activity_api "github.com/fleetdm/fleet/v4/server/activity/api"
|
|
"github.com/fleetdm/fleet/v4/server/config"
|
|
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"
|
|
"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", ptr.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("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", ptr.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", ptr.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,
|
|
}, 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,
|
|
}, 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
|
|
}
|
|
ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
var isHostConnectedToFleet bool
|
|
ds.IsHostConnectedToFleetMDMFunc = func(ctx context.Context, h *fleet.Host) (bool, error) {
|
|
return isHostConnectedToFleet, 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 not connected to fleet
|
|
isHostConnectedToFleet = false
|
|
checkEmptyNudgeConfig(h)
|
|
|
|
// host has MDM turned on but is not enrolled
|
|
isHostConnectedToFleet = 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,
|
|
}, 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, sql.ErrNoRows
|
|
}
|
|
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,
|
|
}, 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
|
|
ds.InsertSoftwareInstallRequestFunc = func(ctx context.Context, hostID uint, softwareInstallerID uint, opts fleet.HostSoftwareInstallOptions) (string, error) {
|
|
require.Equal(t, host.ID, hostID)
|
|
require.Equal(t, installerID, softwareInstallerID)
|
|
capturedOpts = opts
|
|
return "new-uuid", 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.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)
|
|
})
|
|
}
|
|
|
|
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 := mysql.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
|
|
mysql.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
|
|
mysql.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
|
|
mysql.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
|
|
mysql.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")
|
|
}
|
|
|
|
// 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}, nil
|
|
}
|
|
ds.GetHostAwaitingConfigurationFunc = func(ctx context.Context, hostUUID string) (bool, error) {
|
|
return false, nil
|
|
}
|
|
|
|
ctx = test.HostContext(ctx, host)
|
|
return ds, svc, ctx, host
|
|
}
|
|
|
|
t.Run("Windows host awaiting=Pending sets RunSetupExperience", func(t *testing.T) {
|
|
ds, svc, ctx, _ := setupSvc(t)
|
|
ds.GetMDMWindowsAwaitingConfigurationByHostUUIDFunc = func(ctx context.Context, hostUUID string) (fleet.WindowsMDMAwaitingConfiguration, error) {
|
|
return fleet.WindowsMDMAwaitingConfigurationPending, nil
|
|
}
|
|
|
|
cfg, err := svc.GetOrbitConfig(ctx)
|
|
require.NoError(t, err)
|
|
assert.True(t, cfg.Notifications.RunSetupExperience)
|
|
assert.True(t, ds.GetMDMWindowsAwaitingConfigurationByHostUUIDFuncInvoked)
|
|
})
|
|
|
|
t.Run("Windows host awaiting=Active sets RunSetupExperience", func(t *testing.T) {
|
|
ds, svc, ctx, _ := setupSvc(t)
|
|
ds.GetMDMWindowsAwaitingConfigurationByHostUUIDFunc = func(ctx context.Context, hostUUID string) (fleet.WindowsMDMAwaitingConfiguration, error) {
|
|
return fleet.WindowsMDMAwaitingConfigurationActive, nil
|
|
}
|
|
|
|
cfg, err := svc.GetOrbitConfig(ctx)
|
|
require.NoError(t, err)
|
|
assert.True(t, cfg.Notifications.RunSetupExperience)
|
|
})
|
|
|
|
t.Run("Windows host awaiting=None does not set RunSetupExperience", func(t *testing.T) {
|
|
ds, svc, ctx, _ := setupSvc(t)
|
|
ds.GetMDMWindowsAwaitingConfigurationByHostUUIDFunc = func(ctx context.Context, hostUUID string) (fleet.WindowsMDMAwaitingConfiguration, error) {
|
|
return fleet.WindowsMDMAwaitingConfigurationNone, nil
|
|
}
|
|
|
|
cfg, err := svc.GetOrbitConfig(ctx)
|
|
require.NoError(t, err)
|
|
assert.False(t, cfg.Notifications.RunSetupExperience)
|
|
})
|
|
|
|
t.Run("Windows host not enrolled (NotFound) does not set RunSetupExperience", func(t *testing.T) {
|
|
ds, svc, ctx, _ := setupSvc(t)
|
|
ds.GetMDMWindowsAwaitingConfigurationByHostUUIDFunc = func(ctx context.Context, hostUUID string) (fleet.WindowsMDMAwaitingConfiguration, error) {
|
|
return 0, &orbitTestNotFoundErr{}
|
|
}
|
|
|
|
cfg, err := svc.GetOrbitConfig(ctx)
|
|
require.NoError(t, err)
|
|
assert.False(t, cfg.Notifications.RunSetupExperience)
|
|
})
|
|
|
|
t.Run("Windows host with non-NotFound lookup error returns the error", func(t *testing.T) {
|
|
ds, svc, ctx, _ := setupSvc(t)
|
|
ds.GetMDMWindowsAwaitingConfigurationByHostUUIDFunc = func(ctx context.Context, hostUUID string) (fleet.WindowsMDMAwaitingConfiguration, error) {
|
|
return 0, errors.New("transient db error")
|
|
}
|
|
|
|
_, err := svc.GetOrbitConfig(ctx)
|
|
require.Error(t, err)
|
|
})
|
|
|
|
t.Run("non-Windows host does not query awaiting_configuration", 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, ds.GetMDMWindowsAwaitingConfigurationByHostUUIDFuncInvoked,
|
|
"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 }
|