Setup experience script add/replace/delete now record activities (API and GitOps), skipping no-op re-submissions.
2923 lines
116 KiB
Go
2923 lines
116 KiB
Go
package mysql
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft"
|
|
"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 TestSetupExperience(t *testing.T) {
|
|
ds := CreateMySQLDS(t)
|
|
|
|
cases := []struct {
|
|
name string
|
|
fn func(t *testing.T, ds *Datastore)
|
|
}{
|
|
{"EnqueueSetupExperienceItems", testEnqueueSetupExperienceItems},
|
|
{"EnqueueSetupExperienceLinuxScriptPackages", testEnqueueSetupExperienceLinuxScriptPackages},
|
|
{"GetSetupExperienceTitles", testGetSetupExperienceTitles},
|
|
{"SetSetupExperienceTitles", testSetSetupExperienceTitles},
|
|
{"ListSetupExperienceStatusResults", testSetupExperienceStatusResults},
|
|
{"SetupExperienceScriptCRUD", testSetupExperienceScriptCRUD},
|
|
{"TestHostInSetupExperience", testHostInSetupExperience},
|
|
{"TestGetSetupExperienceScriptByID", testGetSetupExperienceScriptByID},
|
|
{"TestUpdateSetupExperienceScriptWhileEnqueued", testUpdateSetupExperienceScriptWhileEnqueued},
|
|
{"TestEnqueueSetupExperienceItemsWindows", testEnqueueSetupExperienceItemsWindows},
|
|
{"EnqueueSetupExperienceItemsWithDisplayName", testEnqueueSetupExperienceItemsWithDisplayName},
|
|
{"UpdateStatusGuardsTerminalStates", testUpdateStatusGuardsTerminalStates},
|
|
{"SetSetupExperienceTitlesOnlyMarksActiveInstaller", testSetSetupExperienceTitlesOnlyMarksActiveInstaller},
|
|
{"PolicyGate", testSetupExperiencePolicyGate},
|
|
{"PolicyGateResultLookups", testSetupExperiencePolicyGateResultLookups},
|
|
{"CrossPlatformShScripts", testSetupExperienceCrossPlatformShScripts},
|
|
{"CrossPlatformPyScripts", testSetupExperienceCrossPlatformPyScripts},
|
|
{"FirstAddedPerTitleNoDoubleQueue", testEnqueueSetupExperienceFirstAddedPerTitle},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
defer TruncateTables(t, ds)
|
|
c.fn(t, ds)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TODO(JVE): this test could probably be simplified and most of the ad-hoc SQL removed.
|
|
// testEnqueueSetupExperienceLinuxScriptPackages tests that Linux script packages (.sh)
|
|
// are properly enqueued for setup experience. This is a regression test for bug #34654.
|
|
func testEnqueueSetupExperienceLinuxScriptPackages(t *testing.T, ds *Datastore) {
|
|
ctx := context.Background()
|
|
|
|
// Create a team
|
|
team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
|
|
require.NoError(t, err)
|
|
|
|
user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
|
|
|
|
// Create a .sh script package installer for Linux
|
|
tfrSh, err := fleet.NewTempFileReader(strings.NewReader("#!/bin/bash\necho hello"), t.TempDir)
|
|
require.NoError(t, err)
|
|
installerIDSh, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
|
InstallScript: "#!/bin/bash\necho installing",
|
|
InstallerFile: tfrSh,
|
|
StorageID: "storage-sh-1",
|
|
Filename: "install.sh",
|
|
Title: "Script Package",
|
|
Version: "1.0",
|
|
Source: "sh_packages",
|
|
UserID: user1.ID,
|
|
TeamID: &team1.ID,
|
|
Platform: "linux",
|
|
Extension: "sh",
|
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Create a .deb package installer for Linux (debian-specific)
|
|
tfrDeb, err := fleet.NewTempFileReader(strings.NewReader("deb package"), t.TempDir)
|
|
require.NoError(t, err)
|
|
installerIDDeb, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
|
InstallScript: "dpkg -i test.deb",
|
|
InstallerFile: tfrDeb,
|
|
StorageID: "storage-deb-1",
|
|
Filename: "test.deb",
|
|
Title: "Deb Package",
|
|
Version: "1.0",
|
|
Source: "deb_packages",
|
|
UserID: user1.ID,
|
|
TeamID: &team1.ID,
|
|
Platform: "linux",
|
|
Extension: "deb",
|
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Create a .tar.gz package installer for Linux (distribution-agnostic like .sh)
|
|
tfrTarGz, err := fleet.NewTempFileReader(strings.NewReader("tarball"), t.TempDir)
|
|
require.NoError(t, err)
|
|
installerIDTarGz, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
|
InstallScript: "tar -xzf test.tar.gz",
|
|
InstallerFile: tfrTarGz,
|
|
StorageID: "storage-tar-1",
|
|
Filename: "test.tar.gz",
|
|
Title: "TarGz Package",
|
|
Version: "1.0",
|
|
Source: "tgz_packages",
|
|
UserID: user1.ID,
|
|
TeamID: &team1.ID,
|
|
Platform: "linux",
|
|
Extension: "tar.gz",
|
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Mark all installers for setup experience
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx, "UPDATE software_installers SET install_during_setup = 1 WHERE id IN (?, ?, ?)",
|
|
installerIDSh, installerIDDeb, installerIDTarGz)
|
|
return err
|
|
})
|
|
|
|
// Test 1: Script package ONLY on Debian host - should enqueue and return true
|
|
t.Run("sh_only_debian", func(t *testing.T) {
|
|
hostDebianShOnly := "debian-sh-only-" + uuid.NewString()
|
|
|
|
// Mark only .sh for setup experience, disable others temporarily
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx, "UPDATE software_installers SET install_during_setup = 0 WHERE id IN (?, ?)", installerIDDeb, installerIDTarGz)
|
|
require.NoError(t, err)
|
|
_, err = q.ExecContext(ctx, "UPDATE software_installers SET install_during_setup = 1 WHERE id = ?", installerIDSh)
|
|
return err
|
|
})
|
|
|
|
anythingEnqueued, err := ds.EnqueueSetupExperienceItems(ctx, "ubuntu", "debian", hostDebianShOnly, team1.ID)
|
|
require.NoError(t, err)
|
|
require.True(t, anythingEnqueued, "BUG #34654: .sh package alone should trigger setup experience")
|
|
|
|
// Verify the .sh package was enqueued
|
|
var rows []setupExperienceInsertTestRows
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.SelectContext(ctx, q, &rows,
|
|
"SELECT host_uuid, name, status, software_installer_id FROM setup_experience_status_results WHERE host_uuid = ?",
|
|
hostDebianShOnly)
|
|
})
|
|
require.Len(t, rows, 1, "BUG #34654: .sh package should be enqueued")
|
|
require.Equal(t, "Script Package", rows[0].Name)
|
|
require.Equal(t, nullableUint(installerIDSh), rows[0].SoftwareInstallerID)
|
|
|
|
// Re-enable all for next tests
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx, "UPDATE software_installers SET install_during_setup = 1 WHERE id IN (?, ?, ?)",
|
|
installerIDSh, installerIDDeb, installerIDTarGz)
|
|
return err
|
|
})
|
|
})
|
|
|
|
// Test 2: Script package on RHEL host - should enqueue (sh is distribution-agnostic)
|
|
t.Run("sh_only_rhel", func(t *testing.T) {
|
|
hostRhelShOnly := "rhel-sh-only-" + uuid.NewString()
|
|
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx, "UPDATE software_installers SET install_during_setup = 0 WHERE id IN (?, ?)", installerIDDeb, installerIDTarGz)
|
|
require.NoError(t, err)
|
|
_, err = q.ExecContext(ctx, "UPDATE software_installers SET install_during_setup = 1 WHERE id = ?", installerIDSh)
|
|
return err
|
|
})
|
|
|
|
anythingEnqueued, err := ds.EnqueueSetupExperienceItems(ctx, "fedora", "rhel", hostRhelShOnly, team1.ID)
|
|
require.NoError(t, err)
|
|
require.True(t, anythingEnqueued, "BUG #34654: .sh package should work on RHEL too")
|
|
|
|
var rows []setupExperienceInsertTestRows
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.SelectContext(ctx, q, &rows,
|
|
"SELECT host_uuid, name, status, software_installer_id FROM setup_experience_status_results WHERE host_uuid = ?",
|
|
hostRhelShOnly)
|
|
})
|
|
require.Len(t, rows, 1)
|
|
require.Equal(t, "Script Package", rows[0].Name)
|
|
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx, "UPDATE software_installers SET install_during_setup = 1 WHERE id IN (?, ?, ?)",
|
|
installerIDSh, installerIDDeb, installerIDTarGz)
|
|
return err
|
|
})
|
|
})
|
|
|
|
// Test 3: Mixed .sh and .deb on Debian host - both should enqueue
|
|
t.Run("mixed_sh_deb_debian", func(t *testing.T) {
|
|
hostDebianMixed := "debian-mixed-" + uuid.NewString()
|
|
|
|
anythingEnqueued, err := ds.EnqueueSetupExperienceItems(ctx, "ubuntu", "debian", hostDebianMixed, team1.ID)
|
|
require.NoError(t, err)
|
|
require.True(t, anythingEnqueued)
|
|
|
|
var rows []setupExperienceInsertTestRows
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.SelectContext(ctx, q, &rows,
|
|
"SELECT host_uuid, name, status, software_installer_id FROM setup_experience_status_results WHERE host_uuid = ? ORDER BY name",
|
|
hostDebianMixed)
|
|
})
|
|
require.Len(t, rows, 3, "All three packages should be enqueued on debian")
|
|
|
|
// Verify all expected packages are there
|
|
names := []string{rows[0].Name, rows[1].Name, rows[2].Name}
|
|
require.Contains(t, names, "Deb Package")
|
|
require.Contains(t, names, "Script Package", "BUG #34654: .sh should be enqueued even when mixed with other packages")
|
|
require.Contains(t, names, "TarGz Package")
|
|
})
|
|
|
|
// Test 4: Mixed .sh and .deb on RHEL host - only .sh and .tar.gz should enqueue (not .deb)
|
|
t.Run("mixed_sh_deb_rhel", func(t *testing.T) {
|
|
hostRhelMixed := "rhel-mixed-" + uuid.NewString()
|
|
|
|
anythingEnqueued, err := ds.EnqueueSetupExperienceItems(ctx, "fedora", "rhel", hostRhelMixed, team1.ID)
|
|
require.NoError(t, err)
|
|
require.True(t, anythingEnqueued)
|
|
|
|
var rows []setupExperienceInsertTestRows
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.SelectContext(ctx, q, &rows,
|
|
"SELECT host_uuid, name, status, software_installer_id FROM setup_experience_status_results WHERE host_uuid = ? ORDER BY name",
|
|
hostRhelMixed)
|
|
})
|
|
require.Len(t, rows, 2, "Only .sh and .tar.gz should be enqueued on RHEL (not .deb)")
|
|
|
|
names := []string{rows[0].Name, rows[1].Name}
|
|
require.Contains(t, names, "Script Package", "BUG #34654: .sh should be enqueued on RHEL")
|
|
require.Contains(t, names, "TarGz Package")
|
|
require.NotContains(t, names, "Deb Package", ".deb should not be enqueued on RHEL")
|
|
})
|
|
}
|
|
|
|
func testEnqueueSetupExperienceItemsWindows(t *testing.T, ds *Datastore) {
|
|
ctx := context.Background()
|
|
team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
|
|
require.NoError(t, err)
|
|
team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"})
|
|
require.NoError(t, err)
|
|
|
|
user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
|
|
|
|
// Create some software installers and add them to setup experience
|
|
tfr1, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
|
|
require.NoError(t, err)
|
|
installerID1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
|
InstallScript: "hello",
|
|
PreInstallQuery: "SELECT 1",
|
|
PostInstallScript: "world",
|
|
UninstallScript: "goodbye",
|
|
InstallerFile: tfr1,
|
|
StorageID: "storage1",
|
|
Filename: "file1",
|
|
Title: "Software1",
|
|
Version: "1.0",
|
|
Source: "apps",
|
|
UserID: user1.ID,
|
|
TeamID: &team1.ID,
|
|
Platform: "windows",
|
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
tfr2, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
|
|
require.NoError(t, err)
|
|
installerID2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
|
InstallScript: "banana",
|
|
PreInstallQuery: "SELECT 3",
|
|
PostInstallScript: "apple",
|
|
InstallerFile: tfr2,
|
|
StorageID: "storage3",
|
|
Filename: "file3",
|
|
Title: "Software2",
|
|
Version: "3.0",
|
|
Source: "apps",
|
|
SelfService: true,
|
|
UserID: user1.ID,
|
|
TeamID: &team2.ID,
|
|
Platform: "windows",
|
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx, "UPDATE software_installers SET install_during_setup = 1 WHERE id IN (?, ?)", installerID1, installerID2)
|
|
return err
|
|
})
|
|
|
|
host1UUID := "11111111-1111-1111-1111-111111111111"
|
|
host2UUID := "22222222-2222-2222-2222-222222222222"
|
|
|
|
// Freshly enrolled host, should get items enqueued
|
|
_, err = ds.NewHost(ctx, &fleet.Host{
|
|
Hostname: "windows-test-1",
|
|
OsqueryHostID: ptr.String("osquery-windows-1"),
|
|
NodeKey: ptr.String("node-key-windows-1"),
|
|
UUID: host1UUID,
|
|
Platform: "windows",
|
|
HardwareSerial: "654321a-1",
|
|
})
|
|
require.NoError(t, err)
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx, "UPDATE hosts SET last_enrolled_at = ? WHERE uuid = ?", time.Now().Add(-1*time.Hour), host1UUID)
|
|
return err
|
|
})
|
|
|
|
// Enroll date > 24 hours ago and is windows. This should not get items enqueued.
|
|
_, err = ds.NewHost(ctx, &fleet.Host{
|
|
Hostname: "windows-test-2",
|
|
OsqueryHostID: ptr.String("osquery-windows-2"),
|
|
NodeKey: ptr.String("node-key-windows-2"),
|
|
UUID: host2UUID,
|
|
Platform: "windows",
|
|
HardwareSerial: "654321b-2",
|
|
})
|
|
require.NoError(t, err)
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx, "UPDATE hosts SET last_enrolled_at = ? WHERE uuid = ?", time.Now().Add(-25*time.Hour), host2UUID)
|
|
return err
|
|
})
|
|
|
|
anythingEnqueued, err := ds.EnqueueSetupExperienceItems(ctx, "windows", "windows", host1UUID, team1.ID)
|
|
require.NoError(t, err)
|
|
require.True(t, anythingEnqueued)
|
|
|
|
anythingEnqueued, err = ds.EnqueueSetupExperienceItems(ctx, "windows", "windows", host2UUID, team2.ID)
|
|
require.NoError(t, err)
|
|
require.False(t, anythingEnqueued)
|
|
|
|
// Re-Autopilot of an existing host: last_enrolled_at is >24h old (the
|
|
// pre-existing record predates this Autopilot cycle), but the host has
|
|
// just MDM-enrolled and is in awaiting_configuration=Pending.
|
|
host3UUID := "33333333-3333-3333-3333-333333333333"
|
|
_, err = ds.NewHost(ctx, &fleet.Host{
|
|
Hostname: "windows-test-3-reautopilot",
|
|
OsqueryHostID: ptr.String("osquery-windows-3"),
|
|
NodeKey: ptr.String("node-key-windows-3"),
|
|
UUID: host3UUID,
|
|
Platform: "windows",
|
|
HardwareSerial: "654321c-3",
|
|
})
|
|
require.NoError(t, err)
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx, "UPDATE hosts SET last_enrolled_at = ? WHERE uuid = ?", time.Now().Add(-25*time.Hour), host3UUID)
|
|
return err
|
|
})
|
|
// Insert a Windows MDM enrollment with awaiting_configuration=Pending,
|
|
// matching what a fresh Autopilot enrollment on the same host would create
|
|
// before last_enrolled_at gets refreshed.
|
|
require.NoError(t, ds.MDMWindowsInsertEnrolledDevice(ctx, &fleet.MDMWindowsEnrolledDevice{
|
|
MDMDeviceID: "device-host3",
|
|
MDMHardwareID: "hw-host3",
|
|
MDMDeviceState: microsoft_mdm.MDMDeviceStateEnrolled,
|
|
MDMDeviceType: "CIMClient_Windows",
|
|
MDMDeviceName: "DESKTOP-H3",
|
|
MDMEnrollType: "ProgrammaticEnrollment",
|
|
MDMEnrollProtoVersion: "5.0",
|
|
MDMEnrollClientVersion: "10.0.19045.2965",
|
|
MDMNotInOOBE: false,
|
|
HostUUID: host3UUID,
|
|
AwaitingConfiguration: fleet.WindowsMDMAwaitingConfigurationPending,
|
|
}))
|
|
|
|
anythingEnqueued, err = ds.EnqueueSetupExperienceItems(ctx, "windows", "windows", host3UUID, team1.ID)
|
|
require.NoError(t, err)
|
|
require.True(t, anythingEnqueued,
|
|
"re-Autopilot of an existing host (>24h old) with awaiting_configuration!=None must bypass the age guard")
|
|
|
|
// Re-BYOD of an existing host: last_enrolled_at is >24h old AND the host's BYOD enrollment never
|
|
// enters awaiting_configuration (not_in_oobe=1). The freshly-created mdm_windows_enrollments row
|
|
// is the signal that this IS a real re-enrollment we want setup-experience for.
|
|
host4UUID := "44444444-4444-4444-4444-444444444444"
|
|
test.NewHost(t, ds, "windows-test-4-rebyod", "", "node-key-windows-4", host4UUID, time.Now(), test.WithPlatform("windows"))
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx, "UPDATE hosts SET last_enrolled_at = ? WHERE uuid = ?", time.Now().Add(-25*time.Hour), host4UUID)
|
|
return err
|
|
})
|
|
require.NoError(t, ds.MDMWindowsInsertEnrolledDevice(ctx, &fleet.MDMWindowsEnrolledDevice{
|
|
MDMDeviceID: "device-host4",
|
|
MDMHardwareID: "hw-host4",
|
|
MDMDeviceState: microsoft_mdm.MDMDeviceStateEnrolled,
|
|
MDMDeviceType: "CIMClient_Windows",
|
|
MDMDeviceName: "DESKTOP-H4",
|
|
MDMEnrollType: "ProgrammaticEnrollment",
|
|
MDMEnrollProtoVersion: "5.0",
|
|
MDMEnrollClientVersion: "10.0.19045.2965",
|
|
MDMNotInOOBE: true,
|
|
HostUUID: host4UUID,
|
|
AwaitingConfiguration: fleet.WindowsMDMAwaitingConfigurationNone,
|
|
}))
|
|
|
|
anythingEnqueued, err = ds.EnqueueSetupExperienceItems(ctx, "windows", "windows", host4UUID, team1.ID)
|
|
require.NoError(t, err)
|
|
require.True(t, anythingEnqueued,
|
|
"re-BYOD of an existing host (>24h old) with a fresh mdm_windows_enrollments row must bypass the age guard")
|
|
|
|
// Original #35717 protection: a fleetd MSI upgrade on a long-running host does NOT create a new
|
|
// mdm_windows_enrollments row, so the existing one is also old. Both fallback signals must miss,
|
|
// and the age guard must still skip enqueueing.
|
|
host5UUID := "55555555-5555-5555-5555-555555555555"
|
|
test.NewHost(t, ds, "windows-test-5-fleetd-upgrade", "", "node-key-windows-5", host5UUID, time.Now(), test.WithPlatform("windows"))
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx, "UPDATE hosts SET last_enrolled_at = ? WHERE uuid = ?", time.Now().Add(-25*time.Hour), host5UUID)
|
|
return err
|
|
})
|
|
require.NoError(t, ds.MDMWindowsInsertEnrolledDevice(ctx, &fleet.MDMWindowsEnrolledDevice{
|
|
MDMDeviceID: "device-host5",
|
|
MDMHardwareID: "hw-host5",
|
|
MDMDeviceState: microsoft_mdm.MDMDeviceStateEnrolled,
|
|
MDMDeviceType: "CIMClient_Windows",
|
|
MDMDeviceName: "DESKTOP-H5",
|
|
MDMEnrollType: "ProgrammaticEnrollment",
|
|
MDMEnrollProtoVersion: "5.0",
|
|
MDMEnrollClientVersion: "10.0.19045.2965",
|
|
MDMNotInOOBE: true,
|
|
HostUUID: host5UUID,
|
|
AwaitingConfiguration: fleet.WindowsMDMAwaitingConfigurationNone,
|
|
}))
|
|
// Backdate the MDM enrollment row beyond the freshEnrollmentWindow (5m) to simulate a long-running
|
|
// host whose orbit just started supporting setup-experience.
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx, "UPDATE mdm_windows_enrollments SET created_at = ? WHERE host_uuid = ?", time.Now().Add(-72*time.Hour), host5UUID)
|
|
return err
|
|
})
|
|
|
|
anythingEnqueued, err = ds.EnqueueSetupExperienceItems(ctx, "windows", "windows", host5UUID, team1.ID)
|
|
require.NoError(t, err)
|
|
require.False(t, anythingEnqueued,
|
|
"long-running host with a stale mdm_windows_enrollments row (e.g. fleetd MSI upgrade per #35717) must still skip enqueueing")
|
|
}
|
|
|
|
func testEnqueueSetupExperienceItems(t *testing.T, ds *Datastore) {
|
|
ctx := context.Background()
|
|
test.CreateInsertGlobalVPPToken(t, ds)
|
|
|
|
// Create some teams
|
|
team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
|
|
require.NoError(t, err)
|
|
team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"})
|
|
require.NoError(t, err)
|
|
team3, err := ds.NewTeam(ctx, &fleet.Team{Name: "team3"})
|
|
require.NoError(t, err)
|
|
|
|
user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
|
|
|
|
// Create some software installers and add them to setup experience
|
|
tfr1, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
|
|
require.NoError(t, err)
|
|
installerID1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
|
InstallScript: "hello",
|
|
PreInstallQuery: "SELECT 1",
|
|
PostInstallScript: "world",
|
|
UninstallScript: "goodbye",
|
|
InstallerFile: tfr1,
|
|
StorageID: "storage1",
|
|
Filename: "file1",
|
|
Title: "Software1",
|
|
Version: "1.0",
|
|
Source: "apps",
|
|
UserID: user1.ID,
|
|
TeamID: &team1.ID,
|
|
Platform: string(fleet.MacOSPlatform),
|
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
tfr2, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
|
|
require.NoError(t, err)
|
|
installerID2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
|
InstallScript: "banana",
|
|
PreInstallQuery: "SELECT 3",
|
|
PostInstallScript: "apple",
|
|
InstallerFile: tfr2,
|
|
StorageID: "storage3",
|
|
Filename: "file3",
|
|
Title: "Software2",
|
|
Version: "3.0",
|
|
Source: "apps",
|
|
SelfService: true,
|
|
UserID: user1.ID,
|
|
TeamID: &team2.ID,
|
|
Platform: string(fleet.MacOSPlatform),
|
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx, "UPDATE software_installers SET install_during_setup = 1 WHERE id IN (?, ?)", installerID1, installerID2)
|
|
return err
|
|
})
|
|
|
|
// Create some VPP apps and add them to setup experience
|
|
app1 := &fleet.VPPApp{Name: "vpp_app_1", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "1", Platform: fleet.MacOSPlatform}}, BundleIdentifier: "b1"}
|
|
vpp1, err := ds.InsertVPPAppWithTeam(ctx, app1, &team1.ID)
|
|
require.NoError(t, err)
|
|
|
|
app2 := &fleet.VPPApp{Name: "vpp_app_2", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "2", Platform: fleet.MacOSPlatform}}, BundleIdentifier: "b2"}
|
|
vpp2, err := ds.InsertVPPAppWithTeam(ctx, app2, &team2.ID)
|
|
require.NoError(t, err)
|
|
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx, "UPDATE vpp_apps_teams SET install_during_setup = 1 WHERE adam_id IN (?, ?)", vpp1.AdamID, vpp2.AdamID)
|
|
return err
|
|
})
|
|
|
|
// Create some scripts and add them to setup experience
|
|
_, err = ds.SetSetupExperienceScript(ctx, &fleet.Script{Name: "script1", ScriptContents: "SCRIPT 1", TeamID: &team1.ID})
|
|
require.NoError(t, err)
|
|
_, err = ds.SetSetupExperienceScript(ctx, &fleet.Script{Name: "script2", ScriptContents: "SCRIPT 2", TeamID: &team2.ID})
|
|
require.NoError(t, err)
|
|
|
|
script1, err := ds.GetSetupExperienceScript(ctx, &team1.ID)
|
|
require.NoError(t, err)
|
|
|
|
script2, err := ds.GetSetupExperienceScript(ctx, &team2.ID)
|
|
require.NoError(t, err)
|
|
|
|
hostTeam1 := "123"
|
|
hostTeam2 := "456"
|
|
hostTeam2Missing := "555"
|
|
hostTeam3 := "789"
|
|
hostTeam1Old := "000"
|
|
hostTeam1New := "007"
|
|
|
|
// No enroll date. This should be treated as a new host and have items enqueued.
|
|
_, err = ds.NewHost(ctx, &fleet.Host{
|
|
Hostname: "macos-test-1",
|
|
OsqueryHostID: ptr.String("osquery-macos-1"),
|
|
NodeKey: ptr.String("node-key-macos-1"),
|
|
UUID: hostTeam1,
|
|
Platform: "darwin",
|
|
HardwareSerial: "654321a",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Enroll date < 24 hours ago. This should be treated as a new host and have items enqueued.
|
|
_, err = ds.NewHost(ctx, &fleet.Host{
|
|
Hostname: "macos-test-2",
|
|
OsqueryHostID: ptr.String("osquery-macos-2"),
|
|
NodeKey: ptr.String("node-key-macos-2"),
|
|
UUID: hostTeam2,
|
|
Platform: "darwin",
|
|
HardwareSerial: "654321a-2",
|
|
})
|
|
require.NoError(t, err)
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx, "UPDATE hosts SET last_enrolled_at = ? WHERE uuid = ?", time.Now().Add(-1*time.Hour), hostTeam2)
|
|
return err
|
|
})
|
|
|
|
// Deliberately not adding a record for the hostTeam2Missing, to verify that
|
|
// we still enqueue items for it if it doesn't exist in the database.
|
|
|
|
// Enroll date > 24 hours ago but is macOS. This should get items enqueued.
|
|
_, err = ds.NewHost(ctx, &fleet.Host{
|
|
Hostname: "macos-test-4",
|
|
OsqueryHostID: ptr.String("osquery-macos-4"),
|
|
NodeKey: ptr.String("node-key-macos-4"),
|
|
UUID: hostTeam1Old,
|
|
Platform: "darwin",
|
|
HardwareSerial: "654321a-4",
|
|
})
|
|
require.NoError(t, err)
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx, "UPDATE hosts SET last_enrolled_at = ? WHERE uuid = ?", time.Now().Add(-25*time.Hour), hostTeam1Old)
|
|
return err
|
|
})
|
|
|
|
// Enroll date of the Fleet "zero time". This should have items enqueued.
|
|
_, err = ds.NewHost(ctx, &fleet.Host{
|
|
Hostname: "macos-test-4",
|
|
OsqueryHostID: ptr.String("osquery-macos-5"),
|
|
NodeKey: ptr.String("node-key-macos-5"),
|
|
UUID: hostTeam1New,
|
|
Platform: "darwin",
|
|
HardwareSerial: "654321a-4",
|
|
})
|
|
require.NoError(t, err)
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx, "UPDATE hosts SET last_enrolled_at = ? WHERE uuid = ?", time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC), hostTeam1New)
|
|
return err
|
|
})
|
|
|
|
anythingEnqueued, err := ds.EnqueueSetupExperienceItems(ctx, "darwin", "darwin", hostTeam1, team1.ID)
|
|
require.NoError(t, err)
|
|
require.True(t, anythingEnqueued)
|
|
awaitingConfig, err := ds.GetHostAwaitingConfiguration(ctx, hostTeam1)
|
|
require.NoError(t, err)
|
|
require.True(t, awaitingConfig)
|
|
|
|
anythingEnqueued, err = ds.EnqueueSetupExperienceItems(ctx, "darwin", "darwin", hostTeam1New, team1.ID)
|
|
require.NoError(t, err)
|
|
require.True(t, anythingEnqueued)
|
|
awaitingConfig, err = ds.GetHostAwaitingConfiguration(ctx, hostTeam1New)
|
|
require.NoError(t, err)
|
|
require.True(t, awaitingConfig)
|
|
|
|
anythingEnqueued, err = ds.EnqueueSetupExperienceItems(ctx, "darwin", "darwin", hostTeam2, team2.ID)
|
|
require.NoError(t, err)
|
|
require.True(t, anythingEnqueued)
|
|
awaitingConfig, err = ds.GetHostAwaitingConfiguration(ctx, hostTeam2)
|
|
require.NoError(t, err)
|
|
require.True(t, awaitingConfig)
|
|
|
|
anythingEnqueued, err = ds.EnqueueSetupExperienceItems(ctx, "darwin", "darwin", hostTeam2Missing, team2.ID)
|
|
require.NoError(t, err)
|
|
require.True(t, anythingEnqueued)
|
|
awaitingConfig, err = ds.GetHostAwaitingConfiguration(ctx, hostTeam2Missing)
|
|
require.NoError(t, err)
|
|
require.True(t, awaitingConfig)
|
|
|
|
anythingEnqueued, err = ds.EnqueueSetupExperienceItems(ctx, "darwin", "darwin", hostTeam3, team3.ID)
|
|
require.NoError(t, err)
|
|
require.False(t, anythingEnqueued)
|
|
// Nothing is configured for setup experience in team 3, so we do not set
|
|
// host_mdm_apple_awaiting_configuration.
|
|
awaitingConfig, err = ds.GetHostAwaitingConfiguration(ctx, hostTeam3)
|
|
require.Error(t, err)
|
|
require.True(t, fleet.IsNotFound(err))
|
|
require.False(t, awaitingConfig)
|
|
|
|
anythingEnqueued, err = ds.EnqueueSetupExperienceItems(ctx, "darwin", "darwin", hostTeam1Old, team1.ID)
|
|
require.NoError(t, err)
|
|
require.True(t, anythingEnqueued)
|
|
// This host enrolled > 24 hours ago, but it's darwin, so we should enqueue items for it.
|
|
awaitingConfig, err = ds.GetHostAwaitingConfiguration(ctx, hostTeam1Old)
|
|
require.NoError(t, err)
|
|
require.True(t, awaitingConfig)
|
|
|
|
seRows := []setupExperienceInsertTestRows{}
|
|
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.SelectContext(ctx, q, &seRows, "SELECT host_uuid, name, status, software_installer_id, setup_experience_script_id, vpp_app_team_id FROM setup_experience_status_results")
|
|
})
|
|
|
|
// five hosts with three items enqueued each.
|
|
require.Len(t, seRows, 15)
|
|
|
|
for _, tc := range []setupExperienceInsertTestRows{
|
|
{
|
|
HostUUID: hostTeam1,
|
|
Name: "Software1",
|
|
Status: "pending",
|
|
SoftwareInstallerID: nullableUint(installerID1),
|
|
},
|
|
{
|
|
HostUUID: hostTeam2,
|
|
Name: "Software2",
|
|
Status: "pending",
|
|
SoftwareInstallerID: nullableUint(installerID2),
|
|
},
|
|
{
|
|
HostUUID: hostTeam1,
|
|
Name: app1.Name,
|
|
Status: "pending",
|
|
VPPAppTeamID: nullableUint(1),
|
|
},
|
|
{
|
|
HostUUID: hostTeam2,
|
|
Name: app2.Name,
|
|
Status: "pending",
|
|
VPPAppTeamID: nullableUint(2),
|
|
},
|
|
{
|
|
HostUUID: hostTeam1,
|
|
Name: "script1",
|
|
Status: "pending",
|
|
ScriptID: nullableUint(script1.ID),
|
|
},
|
|
{
|
|
HostUUID: hostTeam2,
|
|
Name: "script2",
|
|
Status: "pending",
|
|
ScriptID: nullableUint(script2.ID),
|
|
},
|
|
} {
|
|
var found bool
|
|
for _, row := range seRows {
|
|
if row == tc {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("Couldn't find entry in setup_experience_status_results table: %#v", tc)
|
|
}
|
|
}
|
|
|
|
require.Condition(t, func() (success bool) {
|
|
for _, row := range seRows {
|
|
if row.HostUUID == hostTeam3 {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
})
|
|
|
|
// Remove team2's setup experience items
|
|
err = ds.DeleteSetupExperienceScript(ctx, &team2.ID)
|
|
require.NoError(t, err)
|
|
|
|
err = ds.SetSetupExperienceSoftwareTitles(ctx, "darwin", team2.ID, []uint{})
|
|
require.NoError(t, err)
|
|
|
|
anythingEnqueued, err = ds.EnqueueSetupExperienceItems(ctx, "darwin", "darwin", hostTeam1, team1.ID)
|
|
require.NoError(t, err)
|
|
require.True(t, anythingEnqueued)
|
|
|
|
// team2 now has nothing enqueued
|
|
anythingEnqueued, err = ds.EnqueueSetupExperienceItems(ctx, "darwin", "darwin", hostTeam2, team2.ID)
|
|
require.NoError(t, err)
|
|
require.False(t, anythingEnqueued)
|
|
anythingEnqueued, err = ds.EnqueueSetupExperienceItems(ctx, "darwin", "darwin", hostTeam2Missing, team2.ID)
|
|
require.NoError(t, err)
|
|
require.False(t, anythingEnqueued)
|
|
|
|
anythingEnqueued, err = ds.EnqueueSetupExperienceItems(ctx, "darwin", "darwin", hostTeam3, team3.ID)
|
|
require.NoError(t, err)
|
|
require.False(t, anythingEnqueued)
|
|
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.SelectContext(ctx, q, &seRows, "SELECT host_uuid, name, status, software_installer_id, setup_experience_script_id, vpp_app_team_id FROM setup_experience_status_results")
|
|
})
|
|
|
|
// Only the team 1 and team 3 hosts should have items enqueued now.
|
|
// Two hosts with three items each.
|
|
require.Len(t, seRows, 9)
|
|
|
|
for _, tc := range []setupExperienceInsertTestRows{
|
|
{
|
|
HostUUID: hostTeam1,
|
|
Name: "Software1",
|
|
Status: "pending",
|
|
SoftwareInstallerID: nullableUint(installerID1),
|
|
},
|
|
{
|
|
HostUUID: hostTeam1,
|
|
Name: app1.Name,
|
|
Status: "pending",
|
|
VPPAppTeamID: nullableUint(1),
|
|
},
|
|
{
|
|
HostUUID: hostTeam1,
|
|
Name: "script1",
|
|
Status: "pending",
|
|
ScriptID: nullableUint(script1.ID),
|
|
},
|
|
} {
|
|
var found bool
|
|
for _, row := range seRows {
|
|
if row == tc {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("Couldn't find entry in setup_experience_status_results table: %#v", tc)
|
|
}
|
|
}
|
|
|
|
for _, row := range seRows {
|
|
if row.HostUUID == hostTeam2 {
|
|
team := 2
|
|
t.Errorf("team %d shouldn't have any any entries", team)
|
|
}
|
|
}
|
|
}
|
|
|
|
// testEnqueueSetupExperienceItemsWithDisplayName verifies that when a custom
|
|
// display name is set for a software title, the enqueue function uses it to
|
|
// determine the alphabetical install order (instead of the default
|
|
// software_titles.name). This ordering also orders the steps in the
|
|
// setup experience UI. The UI uses the display name if it is set, and
|
|
// the name if not.
|
|
func testEnqueueSetupExperienceItemsWithDisplayName(t *testing.T, ds *Datastore) {
|
|
ctx := context.Background()
|
|
test.CreateInsertGlobalVPPToken(t, ds)
|
|
|
|
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "team_display_name_test"})
|
|
require.NoError(t, err)
|
|
|
|
user := test.NewUser(t, ds, "DisplayNameUser", "displaynameuser@example.com", true)
|
|
|
|
// Create two software installers with titles that sort in a known order:
|
|
// "AAA_Software" < "ZZZ_Software" (alphabetically)
|
|
// We will then assign custom display names that invert this order:
|
|
// "AAA_Software" → "Zulu Custom"
|
|
// "ZZZ_Software" → "Alpha Custom"
|
|
// After enqueue, the rows ordered by id (insert order) should reflect
|
|
// the display-name alphabetical order:
|
|
// id=N → ZZZ_Software (display name "Alpha Custom", sorts first)
|
|
// id=N+1 → AAA_Software (display name "Zulu Custom", sorts second)
|
|
// But the `name` column still stores the original st.name.
|
|
// Note that the setup experience UI will also follow this ordering;
|
|
// it will display "Alpha Custom" and then "Zulu Custom".
|
|
|
|
tfr1, err := fleet.NewTempFileReader(strings.NewReader("hello1"), t.TempDir)
|
|
require.NoError(t, err)
|
|
installerID1, titleID1, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
|
InstallScript: "install1",
|
|
UninstallScript: "uninstall1",
|
|
InstallerFile: tfr1,
|
|
StorageID: "storage_dn_1",
|
|
Filename: "file_dn_1",
|
|
Title: "AAA_Software",
|
|
Version: "1.0",
|
|
Source: "apps",
|
|
UserID: user.ID,
|
|
TeamID: &team.ID,
|
|
Platform: string(fleet.MacOSPlatform),
|
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
tfr2, err := fleet.NewTempFileReader(strings.NewReader("hello2"), t.TempDir)
|
|
require.NoError(t, err)
|
|
installerID2, titleID2, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
|
InstallScript: "install2",
|
|
UninstallScript: "uninstall2",
|
|
InstallerFile: tfr2,
|
|
StorageID: "storage_dn_2",
|
|
Filename: "file_dn_2",
|
|
Title: "ZZZ_Software",
|
|
Version: "2.0",
|
|
Source: "apps",
|
|
UserID: user.ID,
|
|
TeamID: &team.ID,
|
|
Platform: string(fleet.MacOSPlatform),
|
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Mark both installers for setup experience
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx, "UPDATE software_installers SET install_during_setup = 1 WHERE id IN (?, ?)", installerID1, installerID2)
|
|
return err
|
|
})
|
|
|
|
// Set custom display names that invert the alphabetical order
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
if err := updateSoftwareTitleDisplayName(ctx, q, &team.ID, titleID1, "Zulu Custom"); err != nil {
|
|
return err
|
|
}
|
|
return updateSoftwareTitleDisplayName(ctx, q, &team.ID, titleID2, "Alpha Custom")
|
|
})
|
|
|
|
// Create two VPP apps with titles that sort in a known order, then invert with display names.
|
|
vppApp1 := &fleet.VPPApp{
|
|
Name: "AAA_VPP_App",
|
|
BundleIdentifier: "com.aaa.vpp",
|
|
VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "dn_adam_1", Platform: fleet.MacOSPlatform}},
|
|
}
|
|
vpp1, err := ds.InsertVPPAppWithTeam(ctx, vppApp1, &team.ID)
|
|
require.NoError(t, err)
|
|
|
|
vppApp2 := &fleet.VPPApp{
|
|
Name: "ZZZ_VPP_App",
|
|
BundleIdentifier: "com.zzz.vpp",
|
|
VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "dn_adam_2", Platform: fleet.MacOSPlatform}},
|
|
}
|
|
vpp2, err := ds.InsertVPPAppWithTeam(ctx, vppApp2, &team.ID)
|
|
require.NoError(t, err)
|
|
|
|
// Mark both VPP apps for setup experience
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx, "UPDATE vpp_apps_teams SET install_during_setup = 1 WHERE adam_id IN (?, ?)", vpp1.AdamID, vpp2.AdamID)
|
|
return err
|
|
})
|
|
|
|
// Set custom display names for VPP apps (invert order)
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
if err := updateSoftwareTitleDisplayName(ctx, q, &team.ID, vppApp1.TitleID, "Zulu VPP Custom"); err != nil {
|
|
return err
|
|
}
|
|
return updateSoftwareTitleDisplayName(ctx, q, &team.ID, vppApp2.TitleID, "Alpha VPP Custom")
|
|
})
|
|
|
|
// Create a host assigned to the team and enqueue setup experience.
|
|
// The host must be on the team so that ListSetupExperienceResultsByHostUUID
|
|
// can look up the team's display names.
|
|
hostUUID := "host-display-name-test-" + uuid.NewString()
|
|
host1, err := ds.NewHost(ctx, &fleet.Host{
|
|
Hostname: "macos-dn-test",
|
|
OsqueryHostID: ptr.String("osquery-dn-test"),
|
|
NodeKey: ptr.String("node-key-dn-test"),
|
|
UUID: hostUUID,
|
|
Platform: "darwin",
|
|
HardwareSerial: "dn-serial-1",
|
|
})
|
|
require.NoError(t, err)
|
|
err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team.ID, []uint{host1.ID}))
|
|
require.NoError(t, err)
|
|
|
|
anythingEnqueued, err := ds.EnqueueSetupExperienceItems(ctx, "darwin", "darwin", hostUUID, team.ID)
|
|
require.NoError(t, err)
|
|
require.True(t, anythingEnqueued)
|
|
|
|
// --- Verify all rows are globally ordered by display name ---
|
|
// enqueueSetupExperienceItems inserts software (installers and VPP apps)
|
|
// together in a single query ordered by COALESCE(display_name, st.name),
|
|
// so the auto-incremented id reflects the global display-name order.
|
|
// ListSetupExperienceResultsByHostUUID returns rows ordered by sesr.id,
|
|
// preserving that insert order. Scripts are inserted last.
|
|
//
|
|
// Expected order (all software globally sorted by display name):
|
|
// 0. ZZZ_Software (installer, display name "Alpha Custom")
|
|
// 1. ZZZ_VPP_App (VPP app, display name "Alpha VPP Custom")
|
|
// 2. AAA_Software (installer, display name "Zulu Custom")
|
|
// 3. AAA_VPP_App (VPP app, display name "Zulu VPP Custom")
|
|
allResults, err := ds.ListSetupExperienceResultsByHostUUID(ctx, hostUUID, team.ID)
|
|
require.NoError(t, err)
|
|
require.Len(t, allResults, 4, "expected 4 results total (2 installers + 2 VPP apps)")
|
|
|
|
assert.Equal(t, "ZZZ_Software", allResults[0].Name, "row 0: ZZZ_Software (display name 'Alpha Custom')")
|
|
assert.Equal(t, "Alpha Custom", allResults[0].DisplayName, "row 0: display name should be 'Alpha Custom'")
|
|
assert.NotNil(t, allResults[0].SoftwareInstallerID, "row 0: should be a software installer")
|
|
|
|
assert.Equal(t, "ZZZ_VPP_App", allResults[1].Name, "row 1: ZZZ_VPP_App (display name 'Alpha VPP Custom')")
|
|
assert.Equal(t, "Alpha VPP Custom", allResults[1].DisplayName, "row 1: display name should be 'Alpha VPP Custom'")
|
|
assert.NotNil(t, allResults[1].VPPAppTeamID, "row 1: should be a VPP app")
|
|
assert.Less(t, allResults[0].ID, allResults[1].ID)
|
|
|
|
assert.Equal(t, "AAA_Software", allResults[2].Name, "row 2: AAA_Software (display name 'Zulu Custom')")
|
|
assert.Equal(t, "Zulu Custom", allResults[2].DisplayName, "row 2: display name should be 'Zulu Custom'")
|
|
assert.NotNil(t, allResults[2].SoftwareInstallerID, "row 2: should be a software installer")
|
|
assert.Less(t, allResults[1].ID, allResults[2].ID)
|
|
|
|
assert.Equal(t, "AAA_VPP_App", allResults[3].Name, "row 3: AAA_VPP_App (display name 'Zulu VPP Custom')")
|
|
assert.Equal(t, "Zulu VPP Custom", allResults[3].DisplayName, "row 3: display name should be 'Zulu VPP Custom'")
|
|
assert.NotNil(t, allResults[3].VPPAppTeamID, "row 3: should be a VPP app")
|
|
|
|
// --- Verify fallback: no display name → order uses st.name ---
|
|
// Add a third installer and a third VPP app, both without custom display
|
|
// names, then re-enqueue for a new host and verify the globally
|
|
// interleaved order. Items without a display name fall back to st.name.
|
|
tfr3, err := fleet.NewTempFileReader(strings.NewReader("hello3"), t.TempDir)
|
|
require.NoError(t, err)
|
|
_, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
|
InstallScript: "install3",
|
|
UninstallScript: "uninstall3",
|
|
InstallerFile: tfr3,
|
|
StorageID: "storage_dn_3",
|
|
Filename: "file_dn_3",
|
|
Title: "MMM_NoDisplayName",
|
|
Version: "3.0",
|
|
Source: "apps",
|
|
UserID: user.ID,
|
|
TeamID: &team.ID,
|
|
Platform: string(fleet.MacOSPlatform),
|
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx, "UPDATE software_installers SET install_during_setup = 1 WHERE id NOT IN (?, ?)", installerID1, installerID2)
|
|
return err
|
|
})
|
|
|
|
vppApp3 := &fleet.VPPApp{
|
|
Name: "MMM_VPP_NoDisplayName",
|
|
BundleIdentifier: "com.mmm.vpp",
|
|
VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "dn_adam_3", Platform: fleet.MacOSPlatform}},
|
|
}
|
|
vpp3, err := ds.InsertVPPAppWithTeam(ctx, vppApp3, &team.ID)
|
|
require.NoError(t, err)
|
|
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx, "UPDATE vpp_apps_teams SET install_during_setup = 1 WHERE adam_id = ?", vpp3.AdamID)
|
|
return err
|
|
})
|
|
|
|
// Re-enqueue for a new host (also on the team) to pick up all installers and VPP apps.
|
|
hostUUID2 := "host-display-name-fallback-" + uuid.NewString()
|
|
host2, err := ds.NewHost(ctx, &fleet.Host{
|
|
Hostname: "macos-dn-test-2",
|
|
OsqueryHostID: ptr.String("osquery-dn-test-2"),
|
|
NodeKey: ptr.String("node-key-dn-test-2"),
|
|
UUID: hostUUID2,
|
|
Platform: "darwin",
|
|
HardwareSerial: "dn-serial-2",
|
|
})
|
|
require.NoError(t, err)
|
|
err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team.ID, []uint{host2.ID}))
|
|
require.NoError(t, err)
|
|
|
|
anythingEnqueued, err = ds.EnqueueSetupExperienceItems(ctx, "darwin", "darwin", hostUUID2, team.ID)
|
|
require.NoError(t, err)
|
|
require.True(t, anythingEnqueued)
|
|
|
|
// Verify the globally interleaved order across installers and VPP apps.
|
|
// The combined INSERT in enqueueSetupExperienceItems orders by
|
|
// COALESCE(display_name, st.name), and ListSetupExperienceResultsByHostUUID
|
|
// returns rows ordered by sesr.id (i.e. insert order).
|
|
//
|
|
// Expected global order (sorted by COALESCE(display_name, st.name)):
|
|
// 0. ZZZ_Software (installer, display name "Alpha Custom")
|
|
// 1. ZZZ_VPP_App (VPP app, display name "Alpha VPP Custom")
|
|
// 2. MMM_NoDisplayName (installer, no display name → falls back to st.name)
|
|
// 3. MMM_VPP_NoDisplayName (VPP app, no display name → falls back to st.name)
|
|
// 4. AAA_Software (installer, display name "Zulu Custom")
|
|
// 5. AAA_VPP_App (VPP app, display name "Zulu VPP Custom")
|
|
fallbackResults, err := ds.ListSetupExperienceResultsByHostUUID(ctx, hostUUID2, team.ID)
|
|
require.NoError(t, err)
|
|
require.Len(t, fallbackResults, 6, "expected 6 results total (3 installers + 3 VPP apps)")
|
|
|
|
assert.Equal(t, "ZZZ_Software", fallbackResults[0].Name, "row 0: ZZZ_Software (display name 'Alpha Custom')")
|
|
assert.Equal(t, "Alpha Custom", fallbackResults[0].DisplayName)
|
|
assert.NotNil(t, fallbackResults[0].SoftwareInstallerID)
|
|
|
|
assert.Equal(t, "ZZZ_VPP_App", fallbackResults[1].Name, "row 1: ZZZ_VPP_App (display name 'Alpha VPP Custom')")
|
|
assert.Equal(t, "Alpha VPP Custom", fallbackResults[1].DisplayName)
|
|
assert.NotNil(t, fallbackResults[1].VPPAppTeamID)
|
|
assert.Less(t, fallbackResults[0].ID, fallbackResults[1].ID)
|
|
|
|
assert.Equal(t, "MMM_NoDisplayName", fallbackResults[2].Name, "row 2: MMM_NoDisplayName (no display name, falls back to st.name)")
|
|
assert.Empty(t, fallbackResults[2].DisplayName)
|
|
assert.NotNil(t, fallbackResults[2].SoftwareInstallerID)
|
|
assert.Less(t, fallbackResults[1].ID, fallbackResults[2].ID)
|
|
|
|
assert.Equal(t, "MMM_VPP_NoDisplayName", fallbackResults[3].Name, "row 3: MMM_VPP_NoDisplayName (no display name, falls back to st.name)")
|
|
assert.Empty(t, fallbackResults[3].DisplayName)
|
|
assert.NotNil(t, fallbackResults[3].VPPAppTeamID)
|
|
|
|
assert.Equal(t, "AAA_Software", fallbackResults[4].Name, "row 4: AAA_Software (display name 'Zulu Custom')")
|
|
assert.Equal(t, "Zulu Custom", fallbackResults[4].DisplayName)
|
|
assert.NotNil(t, fallbackResults[4].SoftwareInstallerID)
|
|
|
|
assert.Equal(t, "AAA_VPP_App", fallbackResults[5].Name, "row 5: AAA_VPP_App (display name 'Zulu VPP Custom')")
|
|
assert.Equal(t, "Zulu VPP Custom", fallbackResults[5].DisplayName)
|
|
assert.NotNil(t, fallbackResults[5].VPPAppTeamID)
|
|
assert.Less(t, fallbackResults[4].ID, fallbackResults[5].ID)
|
|
}
|
|
|
|
type setupExperienceInsertTestRows struct {
|
|
HostUUID string `db:"host_uuid"`
|
|
Name string `db:"name"`
|
|
Status string `db:"status"`
|
|
SoftwareInstallerID sql.NullInt64 `db:"software_installer_id"`
|
|
ScriptID sql.NullInt64 `db:"setup_experience_script_id"`
|
|
VPPAppTeamID sql.NullInt64 `db:"vpp_app_team_id"`
|
|
}
|
|
|
|
func nullableUint(val uint) sql.NullInt64 {
|
|
return sql.NullInt64{Int64: int64(val), Valid: true} // nolint: gosec
|
|
}
|
|
|
|
func testGetSetupExperienceTitles(t *testing.T, ds *Datastore) {
|
|
ctx := context.Background()
|
|
test.CreateInsertGlobalVPPToken(t, ds)
|
|
|
|
team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
|
|
require.NoError(t, err)
|
|
team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"})
|
|
require.NoError(t, err)
|
|
|
|
user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
|
|
|
|
tfr1, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
|
|
require.NoError(t, err)
|
|
installerID1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
|
InstallScript: "hello",
|
|
PreInstallQuery: "SELECT 1",
|
|
PostInstallScript: "world",
|
|
UninstallScript: "goodbye",
|
|
InstallerFile: tfr1,
|
|
StorageID: "storage1",
|
|
Filename: "file1",
|
|
Title: "file1",
|
|
Version: "1.0",
|
|
Source: "apps",
|
|
UserID: user1.ID,
|
|
TeamID: &team1.ID,
|
|
Platform: string(fleet.MacOSPlatform),
|
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
tfr3, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
|
|
require.NoError(t, err)
|
|
installerID3, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
|
InstallScript: "banana",
|
|
PreInstallQuery: "SELECT 3",
|
|
PostInstallScript: "apple",
|
|
InstallerFile: tfr3,
|
|
StorageID: "storage3",
|
|
Filename: "file3",
|
|
Title: "file3",
|
|
Version: "3.0",
|
|
Source: "apps",
|
|
SelfService: true,
|
|
UserID: user1.ID,
|
|
TeamID: &team2.ID,
|
|
Platform: string(fleet.MacOSPlatform),
|
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
tfr4, err := fleet.NewTempFileReader(strings.NewReader("hello2"), t.TempDir)
|
|
require.NoError(t, err)
|
|
installerID4, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
|
InstallScript: "pear",
|
|
PreInstallQuery: "SELECT 4",
|
|
PostInstallScript: "apple",
|
|
InstallerFile: tfr4,
|
|
StorageID: "storage3",
|
|
Filename: "file4",
|
|
Title: "file4",
|
|
Version: "4.0",
|
|
Source: "apps",
|
|
SelfService: true,
|
|
UserID: user1.ID,
|
|
TeamID: &team2.ID,
|
|
Platform: string(fleet.IOSPlatform),
|
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
tfr5, err := fleet.NewTempFileReader(strings.NewReader("hello3"), t.TempDir)
|
|
require.NoError(t, err)
|
|
installerID5, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
|
InstallScript: "orange",
|
|
PreInstallQuery: "SELECT 5",
|
|
PostInstallScript: "grape",
|
|
InstallerFile: tfr5,
|
|
StorageID: "storage4",
|
|
Filename: "file5",
|
|
Title: "file5",
|
|
Version: "5.0",
|
|
Source: "apps",
|
|
SelfService: true,
|
|
UserID: user1.ID,
|
|
TeamID: &team1.ID,
|
|
Platform: "linux",
|
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
titles, count, meta, err := ds.ListSetupExperienceSoftwareTitles(ctx, "darwin", team1.ID, fleet.ListOptions{})
|
|
require.NoError(t, err)
|
|
assert.Len(t, titles, 1)
|
|
assert.Equal(t, 1, count)
|
|
assert.NotNil(t, meta)
|
|
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx, "UPDATE software_installers SET install_during_setup = 1 WHERE id IN (?, ?, ?, ?)", installerID1, installerID3, installerID4, installerID5)
|
|
return err
|
|
})
|
|
|
|
titles, count, meta, err = ds.ListSetupExperienceSoftwareTitles(ctx, "darwin", team1.ID, fleet.ListOptions{})
|
|
require.NoError(t, err)
|
|
assert.Len(t, titles, 1)
|
|
assert.Equal(t, installerID1, titles[0].ID)
|
|
assert.Equal(t, 1, count)
|
|
assert.NotNil(t, meta)
|
|
|
|
titles, count, meta, err = ds.ListSetupExperienceSoftwareTitles(ctx, "darwin", team2.ID, fleet.ListOptions{})
|
|
require.NoError(t, err)
|
|
assert.Len(t, titles, 1)
|
|
assert.Equal(t, installerID3, titles[0].ID)
|
|
assert.Equal(t, 1, count)
|
|
assert.NotNil(t, meta)
|
|
|
|
app1 := &fleet.VPPApp{Name: "vpp_app_1", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "1", Platform: fleet.MacOSPlatform}}, BundleIdentifier: "b1"}
|
|
_, err = ds.InsertVPPAppWithTeam(ctx, app1, &team1.ID)
|
|
require.NoError(t, err)
|
|
|
|
app2 := &fleet.VPPApp{Name: "vpp_app_2", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "2", Platform: fleet.IOSPlatform}}, BundleIdentifier: "b2"}
|
|
_, err = ds.InsertVPPAppWithTeam(ctx, app2, &team1.ID)
|
|
require.NoError(t, err)
|
|
|
|
app3 := &fleet.VPPApp{Name: "vpp_app_3", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "3", Platform: fleet.MacOSPlatform}}, BundleIdentifier: "b3"}
|
|
_, err = ds.InsertVPPAppWithTeam(ctx, app3, &team2.ID)
|
|
require.NoError(t, err)
|
|
|
|
vpp1, err := ds.InsertVPPAppWithTeam(ctx, app1, &team1.ID)
|
|
require.NoError(t, err)
|
|
|
|
vpp2, err := ds.InsertVPPAppWithTeam(ctx, app2, &team1.ID)
|
|
require.NoError(t, err)
|
|
|
|
vpp3, err := ds.InsertVPPAppWithTeam(ctx, app3, &team2.ID)
|
|
require.NoError(t, err)
|
|
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx, "UPDATE vpp_apps_teams SET install_during_setup = 1 WHERE adam_id IN (?, ?, ?)", vpp1.AdamID, vpp2.AdamID, vpp3.AdamID)
|
|
return err
|
|
})
|
|
|
|
titles, count, meta, err = ds.ListSetupExperienceSoftwareTitles(ctx, "darwin", team1.ID, fleet.ListOptions{})
|
|
require.NoError(t, err)
|
|
assert.Len(t, titles, 2)
|
|
assert.Equal(t, vpp1.AdamID, titles[1].AppStoreApp.AppStoreID)
|
|
assert.Equal(t, 2, count)
|
|
assert.NotNil(t, meta)
|
|
|
|
titles, count, meta, err = ds.ListSetupExperienceSoftwareTitles(ctx, "darwin", team2.ID, fleet.ListOptions{})
|
|
require.NoError(t, err)
|
|
assert.Len(t, titles, 2)
|
|
assert.Equal(t, vpp3.AdamID, titles[1].AppStoreApp.AppStoreID)
|
|
assert.Equal(t, 2, count)
|
|
assert.NotNil(t, meta)
|
|
|
|
_, err = ds.SetSetupExperienceScript(ctx, &fleet.Script{
|
|
TeamID: &team1.ID,
|
|
Name: "the script.sh",
|
|
ScriptContents: "hello",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
sec, err := ds.GetSetupExperienceCount(ctx, "darwin", &team1.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, uint(1), sec.Installers)
|
|
require.Equal(t, uint(1), sec.VPP)
|
|
require.Equal(t, uint(1), sec.Scripts)
|
|
|
|
sec, err = ds.GetSetupExperienceCount(ctx, "linux", &team1.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, uint(1), sec.Installers)
|
|
require.Equal(t, uint(0), sec.VPP)
|
|
require.Equal(t, uint(0), sec.Scripts)
|
|
|
|
sec, err = ds.GetSetupExperienceCount(ctx, "darwin", &team2.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, uint(1), sec.Installers)
|
|
require.Equal(t, uint(1), sec.VPP)
|
|
require.Equal(t, uint(0), sec.Scripts)
|
|
|
|
sec, err = ds.GetSetupExperienceCount(ctx, "darwin", nil)
|
|
require.NoError(t, err)
|
|
require.Equal(t, uint(0), sec.Installers)
|
|
require.Equal(t, uint(0), sec.VPP)
|
|
require.Equal(t, uint(0), sec.Scripts)
|
|
|
|
// add an ipa installer and check that it isn't listed for setup experience
|
|
payload := fleet.UploadSoftwareInstallerPayload{
|
|
TeamID: &team1.ID,
|
|
UserID: user1.ID,
|
|
Title: "ipa_test",
|
|
Filename: "ipa_test.ipa",
|
|
BundleIdentifier: "com.ipa_test",
|
|
StorageID: "testingtesting123",
|
|
Platform: "ios",
|
|
Extension: "ipa",
|
|
Version: "1.2.3",
|
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
|
}
|
|
_, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &payload)
|
|
require.NoError(t, err)
|
|
|
|
// definitely not listed for darwin
|
|
titles, _, _, err = ds.ListSetupExperienceSoftwareTitles(ctx, "darwin", team1.ID, fleet.ListOptions{})
|
|
require.NoError(t, err)
|
|
assert.Len(t, titles, 2)
|
|
require.Equal(t, "file1", titles[0].Name)
|
|
require.Equal(t, "vpp_app_1", titles[1].Name)
|
|
|
|
// but also not listed for ios
|
|
titles, _, _, err = ds.ListSetupExperienceSoftwareTitles(ctx, "ios", team1.ID, fleet.ListOptions{})
|
|
require.NoError(t, err)
|
|
assert.Len(t, titles, 1)
|
|
require.Equal(t, "vpp_app_2", titles[0].Name)
|
|
}
|
|
|
|
func testSetSetupExperienceTitles(t *testing.T, ds *Datastore) {
|
|
ctx := context.Background()
|
|
test.CreateInsertGlobalVPPToken(t, ds)
|
|
|
|
team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
|
|
require.NoError(t, err)
|
|
team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"})
|
|
require.NoError(t, err)
|
|
|
|
user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
|
|
|
|
tfr1, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
|
|
require.NoError(t, err)
|
|
installerID1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
|
InstallScript: "hello",
|
|
PreInstallQuery: "SELECT 1",
|
|
PostInstallScript: "world",
|
|
UninstallScript: "goodbye",
|
|
InstallerFile: tfr1,
|
|
StorageID: "storage1",
|
|
Filename: "file1",
|
|
Title: "file1",
|
|
Version: "1.0",
|
|
Source: "apps",
|
|
UserID: user1.ID,
|
|
TeamID: &team1.ID,
|
|
Platform: string(fleet.MacOSPlatform),
|
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
|
})
|
|
_ = installerID1
|
|
require.NoError(t, err)
|
|
|
|
tfr2, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
|
|
require.NoError(t, err)
|
|
installerID2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
|
InstallScript: "world",
|
|
PreInstallQuery: "SELECT 2",
|
|
PostInstallScript: "hello",
|
|
InstallerFile: tfr2,
|
|
StorageID: "storage2",
|
|
Filename: "file2",
|
|
Title: "file2",
|
|
Version: "2.0",
|
|
Source: "apps",
|
|
UserID: user1.ID,
|
|
TeamID: &team1.ID,
|
|
Platform: string(fleet.MacOSPlatform),
|
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
|
})
|
|
_ = installerID2
|
|
require.NoError(t, err)
|
|
|
|
tfr3, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
|
|
require.NoError(t, err)
|
|
installerID3, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
|
InstallScript: "banana",
|
|
PreInstallQuery: "SELECT 3",
|
|
PostInstallScript: "apple",
|
|
InstallerFile: tfr3,
|
|
StorageID: "storage3",
|
|
Filename: "file3",
|
|
Title: "file3",
|
|
Version: "3.0",
|
|
Source: "apps",
|
|
SelfService: true,
|
|
UserID: user1.ID,
|
|
TeamID: &team2.ID,
|
|
Platform: string(fleet.MacOSPlatform),
|
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
|
})
|
|
_ = installerID3
|
|
require.NoError(t, err)
|
|
|
|
tfr4, err := fleet.NewTempFileReader(strings.NewReader("hello2"), t.TempDir)
|
|
require.NoError(t, err)
|
|
installerID4, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
|
InstallScript: "pear",
|
|
PreInstallQuery: "SELECT 4",
|
|
PostInstallScript: "apple",
|
|
InstallerFile: tfr4,
|
|
StorageID: "storage3",
|
|
Filename: "file4",
|
|
Title: "file4",
|
|
Version: "4.0",
|
|
Source: "apps",
|
|
SelfService: true,
|
|
UserID: user1.ID,
|
|
TeamID: &team2.ID,
|
|
Platform: string(fleet.IOSPlatform),
|
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
|
})
|
|
_ = installerID4
|
|
require.NoError(t, err)
|
|
|
|
titles, count, meta, err := ds.ListSetupExperienceSoftwareTitles(ctx, "darwin", team1.ID, fleet.ListOptions{})
|
|
require.NoError(t, err)
|
|
assert.Len(t, titles, 2)
|
|
assert.Equal(t, 2, count)
|
|
assert.NotNil(t, meta)
|
|
|
|
app1 := &fleet.VPPApp{Name: "vpp_app_1", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "1", Platform: fleet.MacOSPlatform}}, BundleIdentifier: "b1"}
|
|
_, err = ds.InsertVPPAppWithTeam(ctx, app1, &team1.ID)
|
|
require.NoError(t, err)
|
|
|
|
app2 := &fleet.VPPApp{Name: "vpp_app_2", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "2", Platform: fleet.IOSPlatform}}, BundleIdentifier: "b2"}
|
|
_, err = ds.InsertVPPAppWithTeam(ctx, app2, &team1.ID)
|
|
require.NoError(t, err)
|
|
|
|
app3 := &fleet.VPPApp{Name: "vpp_app_3", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "3", Platform: fleet.MacOSPlatform}}, BundleIdentifier: "b3"}
|
|
_, err = ds.InsertVPPAppWithTeam(ctx, app3, &team2.ID)
|
|
require.NoError(t, err)
|
|
|
|
// iOS version of app1, has the same adam ID
|
|
app4 := &fleet.VPPApp{Name: "vpp_app_1: iOS", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "1", Platform: fleet.IOSPlatform}}, BundleIdentifier: "b1"}
|
|
_, err = ds.InsertVPPAppWithTeam(ctx, app4, &team1.ID)
|
|
require.NoError(t, err)
|
|
|
|
titleSoftware := make(map[string]uint)
|
|
titleVPP := make(map[string]uint)
|
|
|
|
softwareTitles, _, _, err := ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{TeamID: &team1.ID}, fleet.TeamFilter{TeamID: &team1.ID})
|
|
require.NoError(t, err)
|
|
|
|
for _, title := range softwareTitles {
|
|
if title.AppStoreApp != nil {
|
|
titleVPP[title.AppStoreApp.AppStoreID+":"+title.AppStoreApp.Platform] = title.ID
|
|
} else if title.SoftwarePackage != nil {
|
|
titleSoftware[title.SoftwarePackage.Name] = title.ID
|
|
}
|
|
}
|
|
|
|
softwareTitles, _, _, err = ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{TeamID: &team2.ID}, fleet.TeamFilter{TeamID: &team2.ID})
|
|
require.NoError(t, err)
|
|
|
|
for _, title := range softwareTitles {
|
|
if title.AppStoreApp != nil {
|
|
titleVPP[title.AppStoreApp.AppStoreID] = title.ID
|
|
} else if title.SoftwarePackage != nil {
|
|
titleSoftware[title.SoftwarePackage.Name] = title.ID
|
|
}
|
|
}
|
|
|
|
// Single installer
|
|
err = ds.SetSetupExperienceSoftwareTitles(ctx, "darwin", team1.ID, []uint{titleSoftware["file1"]})
|
|
require.NoError(t, err)
|
|
|
|
titles, count, meta, err = ds.ListSetupExperienceSoftwareTitles(ctx, "darwin", team1.ID, fleet.ListOptions{})
|
|
require.NoError(t, err)
|
|
assert.Len(t, titles, 3)
|
|
assert.Equal(t, 3, count)
|
|
assert.Equal(t, "file1", titles[0].SoftwarePackage.Name)
|
|
assert.Equal(t, "file2", titles[1].SoftwarePackage.Name)
|
|
assert.Equal(t, "1", titles[2].AppStoreApp.AppStoreID)
|
|
assert.NotNil(t, meta)
|
|
|
|
assert.True(t, *titles[0].SoftwarePackage.InstallDuringSetup)
|
|
assert.False(t, *titles[1].SoftwarePackage.InstallDuringSetup)
|
|
assert.False(t, *titles[2].AppStoreApp.InstallDuringSetup)
|
|
|
|
// Single vpp app replaces installer
|
|
// This VPP app has darwin and ios versions, which shouldn't keep users from adding the darwin one.
|
|
err = ds.SetSetupExperienceSoftwareTitles(ctx, "darwin", team1.ID, []uint{titleVPP["1:darwin"]})
|
|
require.NoError(t, err)
|
|
|
|
titles, count, meta, err = ds.ListSetupExperienceSoftwareTitles(ctx, "darwin", team1.ID, fleet.ListOptions{})
|
|
require.NoError(t, err)
|
|
require.Len(t, titles, 3)
|
|
require.Equal(t, 3, count)
|
|
assert.Equal(t, "file1", titles[0].SoftwarePackage.Name)
|
|
assert.Equal(t, "file2", titles[1].SoftwarePackage.Name)
|
|
assert.Equal(t, "1", titles[2].AppStoreApp.AppStoreID)
|
|
assert.NotNil(t, meta)
|
|
|
|
assert.False(t, *titles[0].SoftwarePackage.InstallDuringSetup)
|
|
assert.False(t, *titles[1].SoftwarePackage.InstallDuringSetup)
|
|
assert.True(t, *titles[2].AppStoreApp.InstallDuringSetup)
|
|
|
|
// Team 2 unaffected
|
|
titles, count, meta, err = ds.ListSetupExperienceSoftwareTitles(ctx, "darwin", team2.ID, fleet.ListOptions{})
|
|
require.NoError(t, err)
|
|
require.Len(t, titles, 2)
|
|
require.Equal(t, 2, count)
|
|
assert.Equal(t, "file3", titles[0].SoftwarePackage.Name)
|
|
assert.Equal(t, "3", titles[1].AppStoreApp.AppStoreID)
|
|
require.NotNil(t, meta)
|
|
|
|
assert.False(t, *titles[0].SoftwarePackage.InstallDuringSetup)
|
|
assert.False(t, *titles[1].AppStoreApp.InstallDuringSetup)
|
|
|
|
// VPP app can be added for iOS
|
|
err = ds.SetSetupExperienceSoftwareTitles(ctx, "ios", team1.ID, []uint{titleVPP["2:ios"]})
|
|
require.NoError(t, err)
|
|
titles, count, meta, err = ds.ListSetupExperienceSoftwareTitles(ctx, "ios", team1.ID, fleet.ListOptions{})
|
|
require.NoError(t, err)
|
|
require.Len(t, titles, 2)
|
|
require.Equal(t, 2, count)
|
|
require.NotNil(t, meta)
|
|
installDuringSetupApps := 0
|
|
for _, title := range titles {
|
|
// iOS should only have vpp apps
|
|
require.NotNil(t, title.AppStoreApp)
|
|
if title.ID == titleVPP["2:ios"] {
|
|
require.True(t, *title.AppStoreApp.InstallDuringSetup)
|
|
installDuringSetupApps++
|
|
} else {
|
|
require.False(t, *title.AppStoreApp.InstallDuringSetup)
|
|
}
|
|
}
|
|
require.Equal(t, 1, installDuringSetupApps)
|
|
|
|
// iOS software. iOS only supports VPP apps so should not check installers
|
|
// even if one somehow exists
|
|
err = ds.SetSetupExperienceSoftwareTitles(ctx, "ios", team2.ID, []uint{titleSoftware["file4"]})
|
|
require.ErrorContains(t, err, "not available")
|
|
|
|
// ios vpp app is invalid for darwin platform
|
|
err = ds.SetSetupExperienceSoftwareTitles(ctx, "darwin", team1.ID, []uint{titleVPP["2:ios"]})
|
|
require.ErrorContains(t, err, "invalid platform for requested AppStoreApp")
|
|
|
|
// wrong team
|
|
err = ds.SetSetupExperienceSoftwareTitles(ctx, "darwin", team1.ID, []uint{titleVPP["3"]})
|
|
require.ErrorContains(t, err, "not available")
|
|
|
|
// good other team assignment
|
|
err = ds.SetSetupExperienceSoftwareTitles(ctx, "darwin", team2.ID, []uint{titleVPP["3"]})
|
|
require.NoError(t, err)
|
|
|
|
// non-existent title ID
|
|
err = ds.SetSetupExperienceSoftwareTitles(ctx, "darwin", team1.ID, []uint{999})
|
|
require.ErrorContains(t, err, "not available")
|
|
|
|
// Failures and other team assignments didn't affected the number of apps on team 1
|
|
titles, count, meta, err = ds.ListSetupExperienceSoftwareTitles(ctx, "darwin", team1.ID, fleet.ListOptions{})
|
|
require.NoError(t, err)
|
|
assert.Len(t, titles, 3)
|
|
assert.Equal(t, 3, count)
|
|
assert.NotNil(t, meta)
|
|
|
|
// Empty slice removes all tiles
|
|
err = ds.SetSetupExperienceSoftwareTitles(ctx, "darwin", team1.ID, []uint{})
|
|
require.NoError(t, err)
|
|
|
|
titles, count, meta, err = ds.ListSetupExperienceSoftwareTitles(ctx, "darwin", team1.ID, fleet.ListOptions{})
|
|
require.NoError(t, err)
|
|
assert.Len(t, titles, 3)
|
|
assert.Equal(t, 3, count)
|
|
assert.NotNil(t, meta)
|
|
|
|
assert.False(t, *titles[0].SoftwarePackage.InstallDuringSetup)
|
|
assert.False(t, *titles[1].SoftwarePackage.InstallDuringSetup)
|
|
assert.False(t, *titles[2].AppStoreApp.InstallDuringSetup)
|
|
}
|
|
|
|
func testSetupExperienceStatusResults(t *testing.T, ds *Datastore) {
|
|
ctx := context.Background()
|
|
hostUUID := uuid.NewString()
|
|
|
|
// Create a software installer
|
|
// We need a new user first
|
|
user, err := ds.NewUser(ctx, &fleet.User{Name: "Foo", Email: "foo@example.com", GlobalRole: ptr.String("admin"), Password: []byte("12characterslong!")})
|
|
require.NoError(t, err)
|
|
installerID, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
|
Filename: "test.pkg",
|
|
Title: "Test Software",
|
|
Version: "1.0.0",
|
|
Source: "apps",
|
|
Platform: "darwin",
|
|
Extension: "pkg",
|
|
UserID: user.ID,
|
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
|
})
|
|
require.NoError(t, err)
|
|
installer, err := ds.GetSoftwareInstallerMetadataByID(ctx, installerID)
|
|
require.NoError(t, err)
|
|
|
|
// VPP setup: create a token so that we can insert a VPP app
|
|
dataToken, err := test.CreateVPPTokenData(time.Now().Add(24*time.Hour), "Donkey Kong", "Jungle")
|
|
require.NoError(t, err)
|
|
tok1, err := ds.InsertVPPToken(ctx, dataToken)
|
|
assert.NoError(t, err)
|
|
_, err = ds.UpdateVPPTokenTeams(ctx, tok1.ID, []uint{})
|
|
assert.NoError(t, err)
|
|
vppApp, err := ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{BundleIdentifier: "com.test.test", Name: "test.app", LatestVersion: "1.0.0"}, nil)
|
|
require.NoError(t, err)
|
|
var vppAppsTeamsID uint
|
|
err = sqlx.GetContext(context.Background(), ds.reader(ctx),
|
|
&vppAppsTeamsID, `SELECT id FROM vpp_apps_teams WHERE adam_id = ?`,
|
|
vppApp.AdamID,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
// TODO: use DS methods once those are written
|
|
var scriptID uint
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
res, err := q.ExecContext(ctx, `INSERT INTO setup_experience_scripts (name) VALUES (?)`,
|
|
"test_script")
|
|
require.NoError(t, err)
|
|
id, err := res.LastInsertId()
|
|
require.NoError(t, err)
|
|
scriptID = uint(id) // nolint: gosec
|
|
return nil
|
|
})
|
|
|
|
insertSetupExperienceStatusResult := func(sesr *fleet.SetupExperienceStatusResult) {
|
|
stmt := `INSERT INTO setup_experience_status_results (id, host_uuid, name, status, software_installer_id, host_software_installs_execution_id, vpp_app_team_id, nano_command_uuid, setup_experience_script_id, script_execution_id, error) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
res, err := q.ExecContext(ctx, stmt,
|
|
sesr.ID, sesr.HostUUID, sesr.Name, sesr.Status, sesr.SoftwareInstallerID, sesr.HostSoftwareInstallsExecutionID, sesr.VPPAppTeamID, sesr.NanoCommandUUID, sesr.SetupExperienceScriptID, sesr.ScriptExecutionID, sesr.Error)
|
|
require.NoError(t, err)
|
|
id, err := res.LastInsertId()
|
|
require.NoError(t, err)
|
|
sesr.ID = uint(id) // nolint: gosec
|
|
return nil
|
|
})
|
|
}
|
|
|
|
expRes := []*fleet.SetupExperienceStatusResult{
|
|
{
|
|
HostUUID: hostUUID,
|
|
Name: "Test Software",
|
|
Status: fleet.SetupExperienceStatusPending,
|
|
SoftwareInstallerID: &installerID,
|
|
SoftwareTitleID: installer.TitleID,
|
|
Source: ptr.String("apps"),
|
|
},
|
|
{
|
|
HostUUID: hostUUID,
|
|
Name: "vpp",
|
|
Status: fleet.SetupExperienceStatusPending,
|
|
VPPAppTeamID: ptr.Uint(vppAppsTeamsID),
|
|
SoftwareTitleID: ptr.Uint(vppApp.TitleID),
|
|
Source: ptr.String("apps"),
|
|
},
|
|
{
|
|
HostUUID: hostUUID,
|
|
Name: "script",
|
|
Status: fleet.SetupExperienceStatusPending,
|
|
SetupExperienceScriptID: ptr.Uint(scriptID),
|
|
Source: nil, // Scripts don't have a source (no software title)
|
|
},
|
|
}
|
|
|
|
for _, r := range expRes {
|
|
insertSetupExperienceStatusResult(r)
|
|
}
|
|
|
|
res, err := ds.ListSetupExperienceResultsByHostUUID(ctx, hostUUID, 0)
|
|
require.NoError(t, err)
|
|
require.Len(t, res, 3)
|
|
for i, s := range expRes {
|
|
require.Equal(t, s, res[i])
|
|
}
|
|
}
|
|
|
|
func testSetupExperienceScriptCRUD(t *testing.T, ds *Datastore) {
|
|
ctx := context.Background()
|
|
|
|
team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
|
|
require.NoError(t, err)
|
|
team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"})
|
|
require.NoError(t, err)
|
|
|
|
// create a script for team1
|
|
wantScript1 := &fleet.Script{
|
|
Name: "script",
|
|
TeamID: &team1.ID,
|
|
ScriptContents: "echo foo",
|
|
}
|
|
|
|
_, err = ds.SetSetupExperienceScript(ctx, wantScript1)
|
|
require.NoError(t, err)
|
|
|
|
// get the script for team1
|
|
gotScript1, err := ds.GetSetupExperienceScript(ctx, &team1.ID)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, gotScript1)
|
|
require.Equal(t, wantScript1.Name, gotScript1.Name)
|
|
require.Equal(t, wantScript1.TeamID, gotScript1.TeamID)
|
|
require.NotZero(t, gotScript1.ScriptContentID)
|
|
|
|
b, err := ds.GetAnyScriptContents(ctx, gotScript1.ScriptContentID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, wantScript1.ScriptContents, string(b))
|
|
|
|
// create a script for team2
|
|
wantScript2 := &fleet.Script{
|
|
Name: "script",
|
|
TeamID: &team2.ID,
|
|
ScriptContents: "echo bar",
|
|
}
|
|
|
|
_, err = ds.SetSetupExperienceScript(ctx, wantScript2)
|
|
require.NoError(t, err)
|
|
|
|
// get the script for team2
|
|
gotScript2, err := ds.GetSetupExperienceScript(ctx, &team2.ID)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, gotScript2)
|
|
require.Equal(t, wantScript2.Name, gotScript2.Name)
|
|
require.Equal(t, wantScript2.TeamID, gotScript2.TeamID)
|
|
require.NotZero(t, gotScript2.ScriptContentID)
|
|
require.NotEqual(t, gotScript1.ScriptContentID, gotScript2.ScriptContentID)
|
|
|
|
b, err = ds.GetAnyScriptContents(ctx, gotScript2.ScriptContentID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, wantScript2.ScriptContents, string(b))
|
|
|
|
// create a script with no team id
|
|
wantScriptNoTeam := &fleet.Script{
|
|
Name: "script",
|
|
ScriptContents: "echo bar",
|
|
}
|
|
|
|
_, err = ds.SetSetupExperienceScript(ctx, wantScriptNoTeam)
|
|
require.NoError(t, err)
|
|
|
|
// get the script nil team id is equivalent to team id 0
|
|
gotScriptNoTeam, err := ds.GetSetupExperienceScript(ctx, nil)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, gotScriptNoTeam)
|
|
require.Equal(t, wantScriptNoTeam.Name, gotScriptNoTeam.Name)
|
|
require.Nil(t, gotScriptNoTeam.TeamID)
|
|
require.NotZero(t, gotScriptNoTeam.ScriptContentID)
|
|
require.Equal(t, gotScript2.ScriptContentID, gotScriptNoTeam.ScriptContentID) // should be the same as team2
|
|
|
|
b, err = ds.GetAnyScriptContents(ctx, gotScriptNoTeam.ScriptContentID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, wantScriptNoTeam.ScriptContents, string(b))
|
|
|
|
// try to create another with name "script" and no team id. Should succeed
|
|
_, err = ds.SetSetupExperienceScript(ctx, &fleet.Script{Name: "script", ScriptContents: "echo baz"})
|
|
require.NoError(t, err)
|
|
|
|
// try to create another script with no team id and a different name. Should succeed
|
|
_, err = ds.SetSetupExperienceScript(ctx, &fleet.Script{Name: "script2", ScriptContents: "echo baz"})
|
|
require.NoError(t, err)
|
|
|
|
// try to add a script for a team that doesn't exist
|
|
var fkErr fleet.ForeignKeyError
|
|
_, err = ds.SetSetupExperienceScript(ctx, &fleet.Script{TeamID: new(uint(42)), Name: "script", ScriptContents: "echo baz"})
|
|
require.Error(t, err)
|
|
require.ErrorAs(t, err, &fkErr)
|
|
|
|
// delete the script for team1
|
|
err = ds.DeleteSetupExperienceScript(ctx, &team1.ID)
|
|
require.NoError(t, err)
|
|
|
|
// get the script for team1
|
|
_, err = ds.GetSetupExperienceScript(ctx, &team1.ID)
|
|
require.Error(t, err)
|
|
require.ErrorIs(t, err, sql.ErrNoRows)
|
|
|
|
// try to delete script for team1 again
|
|
err = ds.DeleteSetupExperienceScript(ctx, &team1.ID)
|
|
require.NoError(t, err) // TODO: confirm if we want to return not found on deletes
|
|
|
|
// try to delete script for team that doesn't exist
|
|
err = ds.DeleteSetupExperienceScript(ctx, ptr.Uint(42))
|
|
require.NoError(t, err) // TODO: confirm if we want to return not found on deletes
|
|
|
|
// add same script for team1 again(even though there will be no update since it doesn't exist)
|
|
_, err = ds.SetSetupExperienceScript(ctx, wantScript1)
|
|
require.NoError(t, err)
|
|
|
|
// get the script for team1
|
|
oldScript1 := gotScript1
|
|
newScript1, err := ds.GetSetupExperienceScript(ctx, &team1.ID)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, newScript1)
|
|
require.Equal(t, wantScript1.Name, newScript1.Name)
|
|
require.Equal(t, wantScript1.TeamID, newScript1.TeamID)
|
|
require.NotZero(t, newScript1.ScriptContentID)
|
|
// script contents are deleted by CleanupUnusedScriptContents not by DeleteSetupExperienceScript
|
|
// so the content id should be the same as the old
|
|
require.Equal(t, oldScript1.ScriptContentID, newScript1.ScriptContentID)
|
|
|
|
// add same script for team1 again
|
|
_, err = ds.SetSetupExperienceScript(ctx, wantScript1)
|
|
require.NoError(t, err)
|
|
|
|
// Verify that the script contents remained the same
|
|
newScript1, err = ds.GetSetupExperienceScript(ctx, &team1.ID)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, newScript1)
|
|
require.Equal(t, wantScript1.Name, newScript1.Name)
|
|
require.Equal(t, wantScript1.TeamID, newScript1.TeamID)
|
|
require.NotZero(t, newScript1.ScriptContentID)
|
|
// script contents are deleted by CleanupUnusedScriptContents not by DeleteSetupExperienceScript
|
|
// so the content id should be the same as the old
|
|
require.Equal(t, oldScript1.ScriptContentID, newScript1.ScriptContentID)
|
|
}
|
|
|
|
func testUpdateSetupExperienceScriptWhileEnqueued(t *testing.T, ds *Datastore) {
|
|
ctx := context.Background()
|
|
|
|
team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
|
|
require.NoError(t, err)
|
|
team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"})
|
|
require.NoError(t, err)
|
|
|
|
// create scripts for team1 and team2
|
|
initialScript1 := &fleet.Script{
|
|
Name: "script",
|
|
TeamID: &team1.ID,
|
|
ScriptContents: "echo foo",
|
|
}
|
|
|
|
initialScript2 := &fleet.Script{
|
|
Name: "script",
|
|
TeamID: &team2.ID,
|
|
ScriptContents: "echo bar",
|
|
}
|
|
|
|
// and an "updated" script for team1
|
|
updatedScript1 := &fleet.Script{
|
|
Name: "script",
|
|
TeamID: &team1.ID,
|
|
ScriptContents: "echo updated foo",
|
|
}
|
|
|
|
changed, err := ds.SetSetupExperienceScript(ctx, initialScript1)
|
|
require.NoError(t, err)
|
|
require.True(t, changed, "creating a script is a change")
|
|
team1OriginalScript, err := ds.GetSetupExperienceScript(ctx, &team1.ID)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, team1OriginalScript)
|
|
|
|
_, err = ds.SetSetupExperienceScript(ctx, initialScript2)
|
|
require.NoError(t, err)
|
|
team2OriginalScript, err := ds.GetSetupExperienceScript(ctx, &team2.ID)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, team2OriginalScript)
|
|
|
|
hostTeam1UUID := "123"
|
|
hostTeam2UUID := "456"
|
|
|
|
anythingEnqueued, err := ds.EnqueueSetupExperienceItems(ctx, "darwin", "darwin", hostTeam1UUID, team1.ID)
|
|
require.NoError(t, err)
|
|
require.True(t, anythingEnqueued)
|
|
|
|
anythingEnqueued, err = ds.EnqueueSetupExperienceItems(ctx, "darwin", "darwin", hostTeam2UUID, team2.ID)
|
|
require.NoError(t, err)
|
|
require.True(t, anythingEnqueued)
|
|
|
|
host1OriginalItems, err := ds.ListSetupExperienceResultsByHostUUID(ctx, hostTeam1UUID, team1.ID)
|
|
require.NoError(t, err)
|
|
require.Len(t, host1OriginalItems, 1)
|
|
require.Equal(t, fleet.SetupExperienceStatusPending, host1OriginalItems[0].Status)
|
|
require.NotNil(t, host1OriginalItems[0].SetupExperienceScriptID)
|
|
require.Equal(t, team1OriginalScript.ID, *host1OriginalItems[0].SetupExperienceScriptID)
|
|
|
|
host2OriginalItems, err := ds.ListSetupExperienceResultsByHostUUID(ctx, hostTeam2UUID, team2.ID)
|
|
require.NoError(t, err)
|
|
require.Len(t, host2OriginalItems, 1)
|
|
require.Equal(t, fleet.SetupExperienceStatusPending, host2OriginalItems[0].Status)
|
|
require.NotNil(t, host2OriginalItems[0].SetupExperienceScriptID)
|
|
require.Equal(t, team2OriginalScript.ID, *host2OriginalItems[0].SetupExperienceScriptID)
|
|
|
|
// "Update" the script for team1 with its original contents which should cause no change to the enqueued execution
|
|
changed, err = ds.SetSetupExperienceScript(ctx, initialScript1)
|
|
require.NoError(t, err)
|
|
require.False(t, changed, "re-submitting identical content is a no-op")
|
|
|
|
team1UpdatedScript, err := ds.GetSetupExperienceScript(ctx, &team1.ID)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, team1UpdatedScript)
|
|
require.Equal(t, team1OriginalScript.ScriptContentID, team1UpdatedScript.ScriptContentID)
|
|
require.Equal(t, team1OriginalScript.ID, team1UpdatedScript.ID)
|
|
|
|
host1NewItems, err := ds.ListSetupExperienceResultsByHostUUID(ctx, hostTeam1UUID, team1.ID)
|
|
require.NoError(t, err)
|
|
require.Len(t, host1NewItems, 1)
|
|
require.Equal(t, team1OriginalScript.ID, *host1NewItems[0].SetupExperienceScriptID)
|
|
|
|
// Should not have perturbed Host 2's enqueued execution either
|
|
host2NewItems, err := ds.ListSetupExperienceResultsByHostUUID(ctx, hostTeam2UUID, team2.ID)
|
|
require.NoError(t, err)
|
|
require.Len(t, host2NewItems, 1)
|
|
require.Equal(t, team2OriginalScript.ID, *host2NewItems[0].SetupExperienceScriptID)
|
|
|
|
// update script for team1 which should delete the enqueued execution
|
|
changed, err = ds.SetSetupExperienceScript(ctx, updatedScript1)
|
|
require.NoError(t, err)
|
|
require.True(t, changed, "replacing content is a change")
|
|
|
|
team1UpdatedScript, err = ds.GetSetupExperienceScript(ctx, &team1.ID)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, team1UpdatedScript)
|
|
require.NotEqual(t, team1OriginalScript.ScriptContentID, team1UpdatedScript.ScriptContentID)
|
|
require.NotEqual(t, team1OriginalScript.ID, team1UpdatedScript.ID)
|
|
|
|
host1NewItems, err = ds.ListSetupExperienceResultsByHostUUID(ctx, hostTeam1UUID, team1.ID)
|
|
require.NoError(t, err)
|
|
require.Len(t, host1NewItems, 0)
|
|
|
|
// Should not have affected host 2's enqueued execution
|
|
host2NewItems, err = ds.ListSetupExperienceResultsByHostUUID(ctx, hostTeam2UUID, team2.ID)
|
|
require.NoError(t, err)
|
|
require.Len(t, host2NewItems, 1)
|
|
require.Equal(t, team2OriginalScript.ID, *host2NewItems[0].SetupExperienceScriptID)
|
|
|
|
// re-enqueue items for host 1, should enqueue the updated script
|
|
anythingEnqueued, err = ds.EnqueueSetupExperienceItems(ctx, "darwin", "darwin", hostTeam1UUID, team1.ID)
|
|
require.NoError(t, err)
|
|
require.True(t, anythingEnqueued)
|
|
|
|
host1NewItems, err = ds.ListSetupExperienceResultsByHostUUID(ctx, hostTeam1UUID, team1.ID)
|
|
require.NoError(t, err)
|
|
require.Len(t, host1NewItems, 1)
|
|
require.Equal(t, team1UpdatedScript.ID, *host1NewItems[0].SetupExperienceScriptID)
|
|
}
|
|
|
|
func testHostInSetupExperience(t *testing.T, ds *Datastore) {
|
|
ctx := context.Background()
|
|
err := ds.SetHostAwaitingConfiguration(ctx, "abc", true)
|
|
require.NoError(t, err)
|
|
|
|
inSetupExperience, err := ds.GetHostAwaitingConfiguration(ctx, "abc")
|
|
require.NoError(t, err)
|
|
require.True(t, inSetupExperience)
|
|
|
|
err = ds.SetHostAwaitingConfiguration(ctx, "abc", false)
|
|
require.NoError(t, err)
|
|
|
|
inSetupExperience, err = ds.GetHostAwaitingConfiguration(ctx, "abc")
|
|
require.NoError(t, err)
|
|
require.False(t, inSetupExperience)
|
|
|
|
// host without a record in the table returns not found
|
|
inSetupExperience, err = ds.GetHostAwaitingConfiguration(ctx, "404")
|
|
require.Error(t, err)
|
|
require.True(t, fleet.IsNotFound(err))
|
|
require.False(t, inSetupExperience)
|
|
}
|
|
|
|
func testUpdateStatusGuardsTerminalStates(t *testing.T, ds *Datastore) {
|
|
ctx := context.Background()
|
|
hostUUID := uuid.NewString()
|
|
|
|
// --- Set up foreign-key references ---
|
|
|
|
// User (required for software installer)
|
|
user, err := ds.NewUser(ctx, &fleet.User{
|
|
Name: "GuardTest",
|
|
Email: "guard@example.com",
|
|
GlobalRole: new("admin"),
|
|
Password: []byte("12characterslong!"),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Software installer
|
|
installerID, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
|
Filename: "guard_test.pkg",
|
|
Title: "Guard Test Software",
|
|
Version: "1.0.0",
|
|
Source: "apps",
|
|
Platform: "darwin",
|
|
Extension: "pkg",
|
|
UserID: user.ID,
|
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// VPP token + app
|
|
dataToken, err := test.CreateVPPTokenData(time.Now().Add(24*time.Hour), "Guard Kong", "GuardJungle")
|
|
require.NoError(t, err)
|
|
tok, err := ds.InsertVPPToken(ctx, dataToken)
|
|
require.NoError(t, err)
|
|
_, err = ds.UpdateVPPTokenTeams(ctx, tok.ID, []uint{})
|
|
require.NoError(t, err)
|
|
vppApp, err := ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{
|
|
BundleIdentifier: "com.guard.test",
|
|
Name: "guard_test.app",
|
|
LatestVersion: "1.0.0",
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
var vppAppsTeamsID uint
|
|
err = sqlx.GetContext(ctx, ds.reader(ctx), &vppAppsTeamsID,
|
|
`SELECT id FROM vpp_apps_teams WHERE adam_id = ?`, vppApp.AdamID)
|
|
require.NoError(t, err)
|
|
|
|
// Setup experience script (raw SQL, same pattern as testSetupExperienceStatusResults)
|
|
var scriptID uint
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
res, err := q.ExecContext(ctx, `INSERT INTO setup_experience_scripts (name) VALUES (?)`, "guard_test_script")
|
|
require.NoError(t, err)
|
|
id, err := res.LastInsertId()
|
|
require.NoError(t, err)
|
|
scriptID = uint(id) //nolint: gosec
|
|
return nil
|
|
})
|
|
|
|
// --- Helpers ---
|
|
|
|
insertRow := func(sesr *fleet.SetupExperienceStatusResult) {
|
|
stmt := `INSERT INTO setup_experience_status_results
|
|
(id, host_uuid, name, status, software_installer_id,
|
|
host_software_installs_execution_id, vpp_app_team_id,
|
|
nano_command_uuid, setup_experience_script_id,
|
|
script_execution_id, error)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
res, err := q.ExecContext(ctx, stmt,
|
|
sesr.ID, sesr.HostUUID, sesr.Name, sesr.Status,
|
|
sesr.SoftwareInstallerID,
|
|
sesr.HostSoftwareInstallsExecutionID,
|
|
sesr.VPPAppTeamID, sesr.NanoCommandUUID,
|
|
sesr.SetupExperienceScriptID,
|
|
sesr.ScriptExecutionID, sesr.Error)
|
|
require.NoError(t, err)
|
|
id, err := res.LastInsertId()
|
|
require.NoError(t, err)
|
|
sesr.ID = uint(id) //nolint: gosec
|
|
return nil
|
|
})
|
|
}
|
|
|
|
readStatus := func(id uint) fleet.SetupExperienceStatusResultStatus {
|
|
var status fleet.SetupExperienceStatusResultStatus
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.GetContext(ctx, q, &status,
|
|
"SELECT status FROM setup_experience_status_results WHERE id = ?", id)
|
|
})
|
|
return status
|
|
}
|
|
|
|
// --- Negative tests: terminal states must not be overwritten ---
|
|
|
|
terminalStatuses := []fleet.SetupExperienceStatusResultStatus{
|
|
fleet.SetupExperienceStatusCancelled,
|
|
fleet.SetupExperienceStatusFailure,
|
|
fleet.SetupExperienceStatusSuccess,
|
|
}
|
|
|
|
for _, termStatus := range terminalStatuses {
|
|
// Software installer row
|
|
execID := uuid.NewString()
|
|
row := &fleet.SetupExperienceStatusResult{
|
|
HostUUID: hostUUID,
|
|
Name: "sw-" + string(termStatus),
|
|
Status: termStatus,
|
|
SoftwareInstallerID: new(installerID),
|
|
HostSoftwareInstallsExecutionID: new(execID),
|
|
}
|
|
insertRow(row)
|
|
updated, err := ds.MaybeUpdateSetupExperienceSoftwareInstallStatus(ctx, hostUUID, execID, fleet.SetupExperienceStatusFailure)
|
|
require.NoError(t, err)
|
|
require.False(t, updated, "software installer row in %s should not be updated", termStatus)
|
|
require.Equal(t, termStatus, readStatus(row.ID))
|
|
|
|
// VPP row
|
|
nanoUUID := uuid.NewString()
|
|
row = &fleet.SetupExperienceStatusResult{
|
|
HostUUID: hostUUID,
|
|
Name: "vpp-" + string(termStatus),
|
|
Status: termStatus,
|
|
VPPAppTeamID: new(vppAppsTeamsID),
|
|
NanoCommandUUID: new(nanoUUID),
|
|
}
|
|
insertRow(row)
|
|
updated, err = ds.MaybeUpdateSetupExperienceVPPStatus(ctx, hostUUID, nanoUUID, fleet.SetupExperienceStatusFailure)
|
|
require.NoError(t, err)
|
|
require.False(t, updated, "VPP row in %s should not be updated", termStatus)
|
|
require.Equal(t, termStatus, readStatus(row.ID))
|
|
|
|
// Script row
|
|
scriptExecID := uuid.NewString()
|
|
row = &fleet.SetupExperienceStatusResult{
|
|
HostUUID: hostUUID,
|
|
Name: "script-" + string(termStatus),
|
|
Status: termStatus,
|
|
SetupExperienceScriptID: new(scriptID),
|
|
ScriptExecutionID: new(scriptExecID),
|
|
}
|
|
insertRow(row)
|
|
updated, err = ds.MaybeUpdateSetupExperienceScriptStatus(ctx, hostUUID, scriptExecID, fleet.SetupExperienceStatusFailure)
|
|
require.NoError(t, err)
|
|
require.False(t, updated, "script row in %s should not be updated", termStatus)
|
|
require.Equal(t, termStatus, readStatus(row.ID))
|
|
}
|
|
|
|
// --- Positive control: pending row CAN be updated ---
|
|
|
|
pendingExecID := uuid.NewString()
|
|
pendingRow := &fleet.SetupExperienceStatusResult{
|
|
HostUUID: hostUUID,
|
|
Name: "sw-pending-positive",
|
|
Status: fleet.SetupExperienceStatusPending,
|
|
SoftwareInstallerID: new(installerID),
|
|
HostSoftwareInstallsExecutionID: new(pendingExecID),
|
|
}
|
|
insertRow(pendingRow)
|
|
updated, err := ds.MaybeUpdateSetupExperienceSoftwareInstallStatus(ctx, hostUUID, pendingExecID, fleet.SetupExperienceStatusFailure)
|
|
require.NoError(t, err)
|
|
require.True(t, updated, "pending row should be updated")
|
|
require.Equal(t, fleet.SetupExperienceStatusFailure, readStatus(pendingRow.ID))
|
|
|
|
// --- Bug-scenario test: canceled VPP row must not flip to failure ---
|
|
|
|
cancelledNanoUUID := uuid.NewString()
|
|
cancelledVPPRow := &fleet.SetupExperienceStatusResult{
|
|
HostUUID: hostUUID,
|
|
Name: "vpp-canceled-bug",
|
|
Status: fleet.SetupExperienceStatusCancelled,
|
|
VPPAppTeamID: new(vppAppsTeamsID),
|
|
NanoCommandUUID: new(cancelledNanoUUID),
|
|
}
|
|
insertRow(cancelledVPPRow)
|
|
updated, err = ds.MaybeUpdateSetupExperienceVPPStatus(ctx, hostUUID, cancelledNanoUUID, fleet.SetupExperienceStatusFailure)
|
|
require.NoError(t, err)
|
|
require.False(t, updated, "cancelled VPP row must not be overwritten by late failure result")
|
|
require.Equal(t, fleet.SetupExperienceStatusCancelled, readStatus(cancelledVPPRow.ID))
|
|
}
|
|
|
|
func testGetSetupExperienceScriptByID(t *testing.T, ds *Datastore) {
|
|
ctx := context.Background()
|
|
|
|
script := &fleet.Script{
|
|
Name: "setup_experience_script",
|
|
ScriptContents: "echo hello",
|
|
}
|
|
|
|
_, err := ds.SetSetupExperienceScript(ctx, script)
|
|
require.NoError(t, err)
|
|
|
|
scriptByTeamID, err := ds.GetSetupExperienceScript(ctx, nil)
|
|
require.NoError(t, err)
|
|
|
|
gotScript, err := ds.GetSetupExperienceScriptByID(ctx, scriptByTeamID.ID)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, script.Name, gotScript.Name)
|
|
require.NotZero(t, gotScript.ScriptContentID)
|
|
|
|
b, err := ds.GetAnyScriptContents(ctx, gotScript.ScriptContentID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, script.ScriptContents, string(b))
|
|
}
|
|
|
|
func testSetSetupExperienceTitlesOnlyMarksActiveInstaller(t *testing.T, ds *Datastore) {
|
|
ctx := t.Context()
|
|
user := test.NewUser(t, ds, "Alice", "alice@example.com", true)
|
|
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "team_setup_exp_active"})
|
|
require.NoError(t, err)
|
|
|
|
fma, err := ds.UpsertMaintainedApp(ctx, &fleet.MaintainedApp{
|
|
Name: "pkg_active",
|
|
Slug: "pkg_active",
|
|
Platform: "darwin",
|
|
UniqueIdentifier: "fleet.pkg_active",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
tfr, err := fleet.NewTempFileReader(strings.NewReader("file contents"), t.TempDir)
|
|
require.NoError(t, err)
|
|
|
|
// Create two cached FMA versions via successive GitOps runs. v1.0 ends
|
|
// up inactive, v2.0 active.
|
|
for _, version := range []string{"1.0", "2.0"} {
|
|
err = ds.BatchSetSoftwareInstallers(ctx, &team.ID, []*fleet.UploadSoftwareInstallerPayload{
|
|
{
|
|
FleetMaintainedAppID: &fma.ID,
|
|
Title: "pkg_active",
|
|
Source: "apps",
|
|
Platform: "darwin",
|
|
PreInstallQuery: "SELECT 1",
|
|
InstallScript: "echo install",
|
|
PostInstallScript: "echo post install",
|
|
UninstallScript: "echo uninstall",
|
|
InstallerFile: tfr,
|
|
StorageID: "storage_id",
|
|
Filename: "pkg_active.pkg",
|
|
Version: version,
|
|
UserID: user.ID,
|
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
|
InstallDuringSetup: new(false),
|
|
SelfService: false,
|
|
TeamID: &team.ID,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// Grab the two installer IDs so we can assert per-row.
|
|
type row struct {
|
|
ID uint `db:"id"`
|
|
Active bool `db:"is_active"`
|
|
InSetup bool `db:"install_during_setup"`
|
|
TitleID uint `db:"title_id"`
|
|
Version string
|
|
}
|
|
var rows []row
|
|
tmFilter := fleet.TeamFilter{User: test.UserAdmin, TeamID: &team.ID}
|
|
titles, _, _, err := ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{TeamID: &team.ID, Platform: "darwin", AvailableForInstall: true}, tmFilter)
|
|
require.NoError(t, err)
|
|
require.Len(t, titles, 1)
|
|
titleID := titles[0].ID
|
|
|
|
ExecAdhocSQL(t, ds, func(tx sqlx.ExtContext) error {
|
|
return sqlx.SelectContext(ctx, tx, &rows, `
|
|
SELECT id, is_active, install_during_setup, title_id, version
|
|
FROM software_installers
|
|
WHERE global_or_team_id = ? AND title_id = ?
|
|
ORDER BY version ASC
|
|
`, team.ID, titleID)
|
|
})
|
|
require.Len(t, rows, 2, "expected 2 cached FMA versions")
|
|
require.False(t, rows[0].Active, "v1.0 should be inactive")
|
|
require.True(t, rows[1].Active, "v2.0 should be active")
|
|
|
|
// Sanity: neither row has install_during_setup set yet (BatchSet was
|
|
// called with InstallDuringSetup=false).
|
|
require.False(t, rows[0].InSetup)
|
|
require.False(t, rows[1].InSetup)
|
|
|
|
// Add the title to setup experience.
|
|
err = ds.SetSetupExperienceSoftwareTitles(ctx, "darwin", team.ID, []uint{titleID})
|
|
require.NoError(t, err)
|
|
|
|
// Re-read: only the active (v2.0) row should have install_during_setup=true.
|
|
rows = nil
|
|
ExecAdhocSQL(t, ds, func(tx sqlx.ExtContext) error {
|
|
return sqlx.SelectContext(ctx, tx, &rows, `
|
|
SELECT id, is_active, install_during_setup, title_id, version
|
|
FROM software_installers
|
|
WHERE global_or_team_id = ? AND title_id = ?
|
|
ORDER BY version ASC
|
|
`, team.ID, titleID)
|
|
})
|
|
require.Len(t, rows, 2)
|
|
require.False(t, rows[0].InSetup, "cached inactive v1.0 must not be marked install_during_setup")
|
|
require.True(t, rows[1].InSetup, "active v2.0 should be marked install_during_setup")
|
|
}
|
|
|
|
// newSetupExperienceInstaller creates a software installer flagged for setup experience and returns its id. Shared by the
|
|
// policy-gate datastore tests to avoid repeating the MatchOrCreateSoftwareInstaller + install_during_setup boilerplate.
|
|
func newSetupExperienceInstaller(t *testing.T, ds *Datastore, userID uint, title, platform, ext, source string, teamID *uint) uint {
|
|
ctx := context.Background()
|
|
tfr, err := fleet.NewTempFileReader(strings.NewReader("installer"), t.TempDir)
|
|
require.NoError(t, err)
|
|
id, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
|
InstallScript: "echo install",
|
|
InstallerFile: tfr,
|
|
StorageID: "pg-storage-" + uuid.NewString(),
|
|
Filename: title + "." + ext,
|
|
Title: title,
|
|
Version: "1.0",
|
|
Source: source,
|
|
UserID: userID,
|
|
TeamID: teamID,
|
|
Platform: platform,
|
|
Extension: ext,
|
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
|
})
|
|
require.NoError(t, err)
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, e := q.ExecContext(ctx, "UPDATE software_installers SET install_during_setup = 1 WHERE id = ?", id)
|
|
return e
|
|
})
|
|
return id
|
|
}
|
|
|
|
// testSetupExperiencePolicyGate verifies that EnqueueSetupExperienceItems marks the policy_gated flag on Windows/Linux
|
|
// setup-experience software rows whose installer has a gating policy (and only those), and that GetSetupExperiencePolicyIDsForHost
|
|
// returns the host's non-terminal gating policy IDs.
|
|
func testSetupExperiencePolicyGate(t *testing.T, ds *Datastore) {
|
|
ctx := context.Background()
|
|
user := test.NewUser(t, ds, "PG User", "pg-user@example.com", true)
|
|
|
|
// readPolicyGated returns a map of software-item name -> recorded policy_gated flag for the host's setup-experience rows.
|
|
readPolicyGated := func(hostUUID string) map[string]bool {
|
|
var rows []struct {
|
|
Name string `db:"name"`
|
|
PolicyGated bool `db:"policy_gated"`
|
|
}
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.SelectContext(ctx, q, &rows, "SELECT name, policy_gated FROM setup_experience_status_results WHERE host_uuid = ?", hostUUID)
|
|
})
|
|
out := map[string]bool{}
|
|
for _, r := range rows {
|
|
out[r.Name] = r.PolicyGated
|
|
}
|
|
return out
|
|
}
|
|
|
|
hostCount := 0
|
|
// newHost creates a real host on the given platform (and team, when non-nil) via the standard helper.
|
|
newHost := func(platform string, teamID *uint) *fleet.Host {
|
|
hostCount++
|
|
opts := []test.NewHostOption{test.WithPlatform(platform)}
|
|
if teamID != nil {
|
|
opts = append(opts, test.WithTeamID(*teamID))
|
|
}
|
|
return test.NewHost(t, ds, fmt.Sprintf("pg-host-%d", hostCount), fmt.Sprintf("10.0.0.%d", hostCount),
|
|
fmt.Sprintf("pg-key-%d", hostCount), uuid.NewString(), time.Now(), opts...)
|
|
}
|
|
|
|
t.Run("windows installer with associated team policy is gated", func(t *testing.T) {
|
|
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "pg-win-associated"})
|
|
require.NoError(t, err)
|
|
installerID := newSetupExperienceInstaller(t, ds, user.ID, "WinGated", "windows", "msi", "programs", &team.ID)
|
|
policy, err := ds.NewTeamPolicy(ctx, team.ID, &user.ID, fleet.PolicyPayload{
|
|
Name: "win-gate",
|
|
Query: "SELECT 1;",
|
|
SoftwareInstallerID: &installerID,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
host := newHost("windows", &team.ID)
|
|
enabled, err := ds.EnqueueSetupExperienceItems(ctx, host.Platform, "windows", host.UUID, team.ID)
|
|
require.NoError(t, err)
|
|
require.True(t, enabled)
|
|
|
|
gatedFlags := readPolicyGated(host.UUID)
|
|
require.Contains(t, gatedFlags, "WinGated")
|
|
require.True(t, gatedFlags["WinGated"])
|
|
|
|
// GetSetupExperiencePolicyIDsForHost returns the gating policy while the item is pending (awaiting its result).
|
|
gated, err := ds.GetSetupExperiencePolicyIDsForHost(ctx, host.UUID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, []uint{policy.ID}, gated)
|
|
|
|
// Once the item moves to the install phase (running with an install execution id), its policy is no longer returned: the
|
|
// gate is resolved, so the policy must not be re-distributed during the install.
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, e := q.ExecContext(ctx,
|
|
"UPDATE setup_experience_status_results SET status = 'running', host_software_installs_execution_id = 'exec-1' WHERE host_uuid = ?", host.UUID)
|
|
return e
|
|
})
|
|
gated, err = ds.GetSetupExperiencePolicyIDsForHost(ctx, host.UUID)
|
|
require.NoError(t, err)
|
|
require.Empty(t, gated, "an item already installing must not have its gating policy re-distributed")
|
|
|
|
// Likewise, once terminal it is not returned.
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, e := q.ExecContext(ctx,
|
|
"UPDATE setup_experience_status_results SET status = 'success', host_software_installs_execution_id = NULL WHERE host_uuid = ?", host.UUID)
|
|
return e
|
|
})
|
|
gated, err = ds.GetSetupExperiencePolicyIDsForHost(ctx, host.UUID)
|
|
require.NoError(t, err)
|
|
require.Empty(t, gated)
|
|
})
|
|
|
|
t.Run("linux installer with associated team policy is gated", func(t *testing.T) {
|
|
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "pg-linux-associated"})
|
|
require.NoError(t, err)
|
|
installerID := newSetupExperienceInstaller(t, ds, user.ID, "LinGated", "linux", "deb", "deb_packages", &team.ID)
|
|
policy, err := ds.NewTeamPolicy(ctx, team.ID, &user.ID, fleet.PolicyPayload{
|
|
Name: "linux-gate",
|
|
Query: "SELECT 1;",
|
|
SoftwareInstallerID: &installerID,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
host := newHost("ubuntu", &team.ID)
|
|
enabled, err := ds.EnqueueSetupExperienceItems(ctx, host.Platform, "debian", host.UUID, team.ID)
|
|
require.NoError(t, err)
|
|
require.True(t, enabled)
|
|
|
|
gatedFlags := readPolicyGated(host.UUID)
|
|
require.Contains(t, gatedFlags, "LinGated")
|
|
require.True(t, gatedFlags["LinGated"], "a Linux .deb setup-experience item with an associated policy must be gated")
|
|
|
|
gated, err := ds.GetSetupExperiencePolicyIDsForHost(ctx, host.UUID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, []uint{policy.ID}, gated)
|
|
})
|
|
|
|
t.Run("windows installer without an associated policy is not gated", func(t *testing.T) {
|
|
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "pg-win-none"})
|
|
require.NoError(t, err)
|
|
newSetupExperienceInstaller(t, ds, user.ID, "WinUngated", "windows", "msi", "programs", &team.ID)
|
|
|
|
host := newHost("windows", &team.ID)
|
|
_, err = ds.EnqueueSetupExperienceItems(ctx, host.Platform, "windows", host.UUID, team.ID)
|
|
require.NoError(t, err)
|
|
|
|
gatedFlags := readPolicyGated(host.UUID)
|
|
require.Contains(t, gatedFlags, "WinUngated")
|
|
require.False(t, gatedFlags["WinUngated"])
|
|
|
|
gated, err := ds.GetSetupExperiencePolicyIDsForHost(ctx, host.UUID)
|
|
require.NoError(t, err)
|
|
require.Empty(t, gated)
|
|
})
|
|
|
|
t.Run("macOS installer with an associated policy is never gated", func(t *testing.T) {
|
|
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "pg-mac"})
|
|
require.NoError(t, err)
|
|
installerID := newSetupExperienceInstaller(t, ds, user.ID, "MacApp", "darwin", "pkg", "apps", &team.ID)
|
|
_, err = ds.NewTeamPolicy(ctx, team.ID, &user.ID, fleet.PolicyPayload{
|
|
Name: "mac-gate",
|
|
Query: "SELECT 1;",
|
|
SoftwareInstallerID: &installerID,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
host := newHost("darwin", &team.ID)
|
|
_, err = ds.EnqueueSetupExperienceItems(ctx, host.Platform, "darwin", host.UUID, team.ID)
|
|
require.NoError(t, err)
|
|
|
|
gatedFlags := readPolicyGated(host.UUID)
|
|
require.Contains(t, gatedFlags, "MacApp")
|
|
require.False(t, gatedFlags["MacApp"], "Apple-platform setup-experience items must never be policy-gated")
|
|
})
|
|
|
|
t.Run("No-team host is gated by a global policy", func(t *testing.T) {
|
|
// global installer (global_or_team_id = 0) and a No-team policy (team_id NULL).
|
|
installerID := newSetupExperienceInstaller(t, ds, user.ID, "NoTeamGated", "windows", "msi", "programs", nil)
|
|
policy, err := ds.NewTeamPolicy(ctx, fleet.PolicyNoTeamID, &user.ID, fleet.PolicyPayload{
|
|
Name: "no-team-gate",
|
|
Query: "SELECT 1;",
|
|
SoftwareInstallerID: &installerID,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
host := newHost("windows", nil) // No-team host (nil TeamID)
|
|
_, err = ds.EnqueueSetupExperienceItems(ctx, host.Platform, "windows", host.UUID, 0)
|
|
require.NoError(t, err)
|
|
|
|
gatedFlags := readPolicyGated(host.UUID)
|
|
require.Contains(t, gatedFlags, "NoTeamGated")
|
|
require.True(t, gatedFlags["NoTeamGated"], "global policy must gate a No-team host (teamID 0 maps to team_id IS NULL)")
|
|
|
|
gated, err := ds.GetSetupExperiencePolicyIDsForHost(ctx, host.UUID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, []uint{policy.ID}, gated, "the No-team gating policy must be distributed during setup")
|
|
})
|
|
|
|
t.Run("multiple policies for one installer -> all gate", func(t *testing.T) {
|
|
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "pg-multi"})
|
|
require.NoError(t, err)
|
|
installerID := newSetupExperienceInstaller(t, ds, user.ID, "MultiGated", "windows", "msi", "programs", &team.ID)
|
|
p1, err := ds.NewTeamPolicy(ctx, team.ID, &user.ID, fleet.PolicyPayload{Name: "multi-a", Query: "SELECT 1;", SoftwareInstallerID: &installerID})
|
|
require.NoError(t, err)
|
|
p2, err := ds.NewTeamPolicy(ctx, team.ID, &user.ID, fleet.PolicyPayload{Name: "multi-b", Query: "SELECT 1;", SoftwareInstallerID: &installerID})
|
|
require.NoError(t, err)
|
|
|
|
host := newHost("windows", &team.ID)
|
|
_, err = ds.EnqueueSetupExperienceItems(ctx, host.Platform, "windows", host.UUID, team.ID)
|
|
require.NoError(t, err)
|
|
|
|
// An installer with any gating policy marks the item gated; the marker is a single boolean regardless of policy count.
|
|
gatedFlags := readPolicyGated(host.UUID)
|
|
require.True(t, gatedFlags["MultiGated"])
|
|
|
|
// The item is gated by ALL of the installer's policies, so both must be returned for evaluation and distribution.
|
|
forInstaller, err := ds.GetSetupExperiencePolicyIDsForInstaller(ctx, installerID)
|
|
require.NoError(t, err)
|
|
require.ElementsMatch(t, []uint{p1.ID, p2.ID}, forInstaller)
|
|
|
|
forHost, err := ds.GetSetupExperiencePolicyIDsForHost(ctx, host.UUID)
|
|
require.NoError(t, err)
|
|
require.ElementsMatch(t, []uint{p1.ID, p2.ID}, forHost, "all of the installer's gating policies must be distributed during setup, not just the marker")
|
|
})
|
|
}
|
|
|
|
// testSetupExperiencePolicyGateResultLookups verifies GetSetupExperiencePolicyResult freshness handling and
|
|
// PolicyQueriesForHostFiltered scoping.
|
|
func testSetupExperiencePolicyGateResultLookups(t *testing.T, ds *Datastore) {
|
|
ctx := context.Background()
|
|
user := test.NewUser(t, ds, "PG2 User", "pg2-user@example.com", true)
|
|
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "pg-lookups"})
|
|
require.NoError(t, err)
|
|
|
|
// enrolledAt is the freshness cutoff passed explicitly to GetSetupExperiencePolicyResult below; it's independent of the host's
|
|
// own last_enrolled_at (which these lookups don't read).
|
|
enrolledAt := time.Now().UTC().Truncate(time.Second)
|
|
host := test.NewHost(t, ds, "pg-lookup-host", "10.0.0.50", "pg-lookup-nodekey", "pg-lookup-uuid", time.Now(),
|
|
test.WithPlatform("windows"), test.WithTeamID(team.ID))
|
|
|
|
policyPass, err := ds.NewTeamPolicy(ctx, team.ID, &user.ID, fleet.PolicyPayload{Name: "pass", Query: "SELECT 1;", Platform: "windows"})
|
|
require.NoError(t, err)
|
|
policyFail, err := ds.NewTeamPolicy(ctx, team.ID, &user.ID, fleet.PolicyPayload{Name: "fail", Query: "SELECT 1;", Platform: "windows"})
|
|
require.NoError(t, err)
|
|
policyOther, err := ds.NewTeamPolicy(ctx, team.ID, &user.ID, fleet.PolicyPayload{Name: "other", Query: "SELECT 1;", Platform: "windows"})
|
|
require.NoError(t, err)
|
|
|
|
t.Run("GetSetupExperiencePolicyResult freshness", func(t *testing.T) {
|
|
// No membership row yet -> nil (still waiting).
|
|
res, err := ds.GetSetupExperiencePolicyResult(ctx, host.ID, policyPass.ID, enrolledAt)
|
|
require.NoError(t, err)
|
|
require.Nil(t, res)
|
|
|
|
// A fresh passing result (updated_at >= enrolledAt) is returned.
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, e := q.ExecContext(ctx,
|
|
"INSERT INTO policy_membership (policy_id, host_id, passes, updated_at) VALUES (?, ?, 1, ?)",
|
|
policyPass.ID, host.ID, enrolledAt.Add(time.Minute))
|
|
return e
|
|
})
|
|
res, err = ds.GetSetupExperiencePolicyResult(ctx, host.ID, policyPass.ID, enrolledAt)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, res)
|
|
require.True(t, *res)
|
|
|
|
// A stale result (updated_at < enrolledAt, i.e. from a previous enrollment) is ignored.
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, e := q.ExecContext(ctx,
|
|
"INSERT INTO policy_membership (policy_id, host_id, passes, updated_at) VALUES (?, ?, 0, ?)",
|
|
policyFail.ID, host.ID, enrolledAt.Add(-time.Hour))
|
|
return e
|
|
})
|
|
res, err = ds.GetSetupExperiencePolicyResult(ctx, host.ID, policyFail.ID, enrolledAt)
|
|
require.NoError(t, err)
|
|
require.Nil(t, res, "a result recorded before last_enrolled_at must be treated as stale")
|
|
})
|
|
|
|
t.Run("PolicyQueriesForHostFiltered returns only requested in-scope policies", func(t *testing.T) {
|
|
// Restrict to policyPass and policyFail; policyOther must not be returned.
|
|
queries, err := ds.PolicyQueriesForHostFiltered(ctx, host, []uint{policyPass.ID, policyFail.ID})
|
|
require.NoError(t, err)
|
|
require.Len(t, queries, 2)
|
|
require.Contains(t, queries, fmt.Sprint(policyPass.ID))
|
|
require.Contains(t, queries, fmt.Sprint(policyFail.ID))
|
|
require.NotContains(t, queries, fmt.Sprint(policyOther.ID))
|
|
|
|
// Empty input -> empty result (no full-team fallback).
|
|
queries, err = ds.PolicyQueriesForHostFiltered(ctx, host, nil)
|
|
require.NoError(t, err)
|
|
require.Empty(t, queries)
|
|
})
|
|
|
|
t.Run("PolicyQueriesForHostFiltered excludes a platform-scoped-out policy (out-of-scope fallback)", func(t *testing.T) {
|
|
darwinOnly, err := ds.NewTeamPolicy(ctx, team.ID, &user.ID, fleet.PolicyPayload{Name: "darwin-only", Query: "SELECT 1;", Platform: "darwin"})
|
|
require.NoError(t, err)
|
|
queries, err := ds.PolicyQueriesForHostFiltered(ctx, host, []uint{darwinOnly.ID})
|
|
require.NoError(t, err)
|
|
require.Empty(t, queries, "a policy whose platform scope excludes the windows host must not be returned")
|
|
})
|
|
|
|
t.Run("PolicyQueriesForHostFiltered respects include/exclude label scope", func(t *testing.T) {
|
|
labelIn, err := ds.NewLabel(ctx, &fleet.Label{Name: "pg-label-in-" + uuid.NewString(), Query: "SELECT 1;"})
|
|
require.NoError(t, err)
|
|
labelOut, err := ds.NewLabel(ctx, &fleet.Label{Name: "pg-label-out-" + uuid.NewString(), Query: "SELECT 1;"})
|
|
require.NoError(t, err)
|
|
require.NoError(t, ds.AddLabelsToHost(ctx, host.ID, []uint{labelIn.ID})) // host is a member of labelIn only
|
|
|
|
// withLabels creates a windows team policy and attaches the given label scope via SavePolicy (the datastore path that
|
|
// resolves label idents into policy_labels).
|
|
withLabels := func(name string, include, exclude []fleet.LabelIdent) *fleet.Policy {
|
|
p, err := ds.NewTeamPolicy(ctx, team.ID, &user.ID, fleet.PolicyPayload{Name: name, Query: "SELECT 1;", Platform: "windows"})
|
|
require.NoError(t, err)
|
|
p.LabelsIncludeAny = include
|
|
p.LabelsExcludeAny = exclude
|
|
require.NoError(t, ds.SavePolicy(ctx, p, false, false))
|
|
return p
|
|
}
|
|
includeMatch := withLabels("include-match", []fleet.LabelIdent{{LabelName: labelIn.Name}}, nil)
|
|
includeMiss := withLabels("include-miss", []fleet.LabelIdent{{LabelName: labelOut.Name}}, nil)
|
|
excludeHit := withLabels("exclude-hit", nil, []fleet.LabelIdent{{LabelName: labelIn.Name}})
|
|
|
|
queries, err := ds.PolicyQueriesForHostFiltered(ctx, host, []uint{includeMatch.ID, includeMiss.ID, excludeHit.ID})
|
|
require.NoError(t, err)
|
|
require.Contains(t, queries, fmt.Sprint(includeMatch.ID), "host is in the include label -> policy applies")
|
|
require.NotContains(t, queries, fmt.Sprint(includeMiss.ID), "host is not in the include label -> policy excluded")
|
|
require.NotContains(t, queries, fmt.Sprint(excludeHit.ID), "host is in the exclude label -> policy excluded")
|
|
require.Len(t, queries, 1)
|
|
})
|
|
|
|
t.Run("ClearHostPolicyMembershipForPolicies removes only the given policies", func(t *testing.T) {
|
|
// Seed membership for two policies.
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, e := q.ExecContext(ctx,
|
|
"INSERT INTO policy_membership (policy_id, host_id, passes) VALUES (?, ?, 1), (?, ?, 1) "+
|
|
"ON DUPLICATE KEY UPDATE passes = VALUES(passes)",
|
|
policyFail.ID, host.ID, policyOther.ID, host.ID)
|
|
return e
|
|
})
|
|
require.NoError(t, ds.ClearHostPolicyMembershipForPolicies(ctx, host.ID, []uint{policyFail.ID}))
|
|
|
|
var remaining []uint
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.SelectContext(ctx, q, &remaining, "SELECT policy_id FROM policy_membership WHERE host_id = ? ORDER BY policy_id", host.ID)
|
|
})
|
|
require.NotContains(t, remaining, policyFail.ID, "cleared policy's membership must be gone")
|
|
require.Contains(t, remaining, policyOther.ID, "other policies' membership must be untouched")
|
|
|
|
// Empty input is a no-op.
|
|
require.NoError(t, ds.ClearHostPolicyMembershipForPolicies(ctx, host.ID, nil))
|
|
})
|
|
|
|
t.Run("ClearHostPolicyUpdatedAt resets the host policy clock to the stale sentinel", func(t *testing.T) {
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, e := q.ExecContext(ctx, "UPDATE hosts SET policy_updated_at = NOW() WHERE id = ?", host.ID)
|
|
return e
|
|
})
|
|
require.NoError(t, ds.ClearHostPolicyUpdatedAt(ctx, host.ID))
|
|
|
|
var updatedAt time.Time
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.GetContext(ctx, q, &updatedAt, "SELECT policy_updated_at FROM hosts WHERE id = ?", host.ID)
|
|
})
|
|
require.True(t, updatedAt.Before(enrolledAt), "policy_updated_at must be reset to a stale value so the full policy set re-runs")
|
|
})
|
|
}
|
|
|
|
func testSetupExperienceCrossPlatformShScripts(t *testing.T, ds *Datastore) {
|
|
ctx := context.Background()
|
|
|
|
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "team-cross-plat"})
|
|
require.NoError(t, err)
|
|
|
|
user := test.NewUser(t, ds, "Alice", "alice@example.com", true)
|
|
|
|
// .sh scripts are stored as platform='linux' but can also run on darwin.
|
|
tfrSh, err := fleet.NewTempFileReader(strings.NewReader("#!/bin/sh\necho hello"), t.TempDir)
|
|
require.NoError(t, err)
|
|
_, shTitleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
|
InstallScript: "#!/bin/sh\necho install",
|
|
InstallerFile: tfrSh,
|
|
StorageID: "storage-sh-cross",
|
|
Filename: "cross.sh",
|
|
Title: "Cross Platform Script",
|
|
Version: "1.0",
|
|
Source: "sh_packages",
|
|
UserID: user.ID,
|
|
TeamID: &team.ID,
|
|
Platform: "linux",
|
|
Extension: "sh",
|
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
tfrPkg, err := fleet.NewTempFileReader(strings.NewReader("pkg content"), t.TempDir)
|
|
require.NoError(t, err)
|
|
_, macosTitleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
|
InstallScript: "installer -pkg mac.pkg -target /",
|
|
UninstallScript: "rm -rf /Applications/mac.app",
|
|
InstallerFile: tfrPkg,
|
|
StorageID: "storage-pkg-cross",
|
|
Filename: "mac.pkg",
|
|
Title: "Mac App",
|
|
Version: "1.0",
|
|
Source: "apps",
|
|
UserID: user.ID,
|
|
TeamID: &team.ID,
|
|
Platform: string(fleet.MacOSPlatform),
|
|
Extension: "pkg",
|
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
t.Run("sh appears in both linux and darwin listings", func(t *testing.T) {
|
|
linuxTitles, _, _, err := ds.ListSetupExperienceSoftwareTitles(ctx, "linux", team.ID, fleet.ListOptions{})
|
|
require.NoError(t, err)
|
|
linuxNames := make([]string, 0, len(linuxTitles))
|
|
for _, tt := range linuxTitles {
|
|
if tt.SoftwarePackage != nil {
|
|
linuxNames = append(linuxNames, tt.SoftwarePackage.Name)
|
|
}
|
|
}
|
|
assert.Contains(t, linuxNames, "cross.sh")
|
|
|
|
macosTitles, _, _, err := ds.ListSetupExperienceSoftwareTitles(ctx, "darwin", team.ID, fleet.ListOptions{})
|
|
require.NoError(t, err)
|
|
macosNames := make([]string, 0, len(macosTitles))
|
|
for _, tt := range macosTitles {
|
|
if tt.SoftwarePackage != nil {
|
|
macosNames = append(macosNames, tt.SoftwarePackage.Name)
|
|
}
|
|
}
|
|
assert.Contains(t, macosNames, "cross.sh")
|
|
assert.Contains(t, macosNames, "mac.pkg")
|
|
})
|
|
|
|
t.Run("saving macOS selection does not clear Linux native selection", func(t *testing.T) {
|
|
err := ds.SetSetupExperienceSoftwareTitles(ctx, "linux", team.ID, []uint{shTitleID})
|
|
require.NoError(t, err)
|
|
|
|
err = ds.SetSetupExperienceSoftwareTitles(ctx, "darwin", team.ID, []uint{shTitleID, macosTitleID})
|
|
require.NoError(t, err)
|
|
|
|
linuxTitles, _, _, err := ds.ListSetupExperienceSoftwareTitles(ctx, "linux", team.ID, fleet.ListOptions{})
|
|
require.NoError(t, err)
|
|
var linuxShSelected bool
|
|
for _, tt := range linuxTitles {
|
|
if tt.SoftwarePackage != nil && tt.SoftwarePackage.Name == "cross.sh" {
|
|
linuxShSelected = *tt.SoftwarePackage.InstallDuringSetup
|
|
}
|
|
}
|
|
assert.True(t, linuxShSelected)
|
|
|
|
macosTitles, _, _, err := ds.ListSetupExperienceSoftwareTitles(ctx, "darwin", team.ID, fleet.ListOptions{})
|
|
require.NoError(t, err)
|
|
selectedNames := make([]string, 0)
|
|
for _, tt := range macosTitles {
|
|
if tt.SoftwarePackage != nil && tt.SoftwarePackage.InstallDuringSetup != nil && *tt.SoftwarePackage.InstallDuringSetup {
|
|
selectedNames = append(selectedNames, tt.SoftwarePackage.Name)
|
|
}
|
|
}
|
|
assert.Contains(t, selectedNames, "cross.sh")
|
|
assert.Contains(t, selectedNames, "mac.pkg")
|
|
})
|
|
|
|
t.Run("clearing macOS selection does not clear Linux native selection", func(t *testing.T) {
|
|
err := ds.SetSetupExperienceSoftwareTitles(ctx, "linux", team.ID, []uint{shTitleID})
|
|
require.NoError(t, err)
|
|
err = ds.SetSetupExperienceSoftwareTitles(ctx, "darwin", team.ID, []uint{shTitleID})
|
|
require.NoError(t, err)
|
|
|
|
err = ds.SetSetupExperienceSoftwareTitles(ctx, "darwin", team.ID, []uint{})
|
|
require.NoError(t, err)
|
|
|
|
linuxTitles, _, _, err := ds.ListSetupExperienceSoftwareTitles(ctx, "linux", team.ID, fleet.ListOptions{})
|
|
require.NoError(t, err)
|
|
var linuxShSelected bool
|
|
for _, tt := range linuxTitles {
|
|
if tt.SoftwarePackage != nil && tt.SoftwarePackage.Name == "cross.sh" {
|
|
linuxShSelected = *tt.SoftwarePackage.InstallDuringSetup
|
|
}
|
|
}
|
|
assert.True(t, linuxShSelected)
|
|
|
|
macosTitles, _, _, err := ds.ListSetupExperienceSoftwareTitles(ctx, "darwin", team.ID, fleet.ListOptions{})
|
|
require.NoError(t, err)
|
|
var macosShSelected bool
|
|
for _, tt := range macosTitles {
|
|
if tt.SoftwarePackage != nil && tt.SoftwarePackage.Name == "cross.sh" && tt.SoftwarePackage.InstallDuringSetup != nil {
|
|
macosShSelected = *tt.SoftwarePackage.InstallDuringSetup
|
|
}
|
|
}
|
|
assert.False(t, macosShSelected)
|
|
})
|
|
|
|
t.Run("darwin host enqueues cross-selected .sh", func(t *testing.T) {
|
|
err := ds.SetSetupExperienceSoftwareTitles(ctx, "darwin", team.ID, []uint{shTitleID})
|
|
require.NoError(t, err)
|
|
|
|
darwinUUID := uuid.NewString()
|
|
_, err = ds.NewHost(ctx, &fleet.Host{
|
|
Hostname: "darwin-host-" + darwinUUID,
|
|
UUID: darwinUUID,
|
|
Platform: "darwin",
|
|
TeamID: &team.ID,
|
|
OsqueryHostID: new("oq-darwin-" + darwinUUID),
|
|
NodeKey: new("nk-darwin-" + darwinUUID),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
enrolled, err := ds.EnqueueSetupExperienceItems(ctx, "darwin", "darwin", darwinUUID, team.ID)
|
|
require.NoError(t, err)
|
|
assert.True(t, enrolled)
|
|
|
|
var names []string
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.SelectContext(ctx, q, &names,
|
|
`SELECT name FROM setup_experience_status_results WHERE host_uuid = ?`,
|
|
darwinUUID)
|
|
})
|
|
assert.Contains(t, names, "Cross Platform Script")
|
|
})
|
|
|
|
t.Run("linux host is unaffected by darwin-only cross selection", func(t *testing.T) {
|
|
err := ds.SetSetupExperienceSoftwareTitles(ctx, "darwin", team.ID, []uint{shTitleID})
|
|
require.NoError(t, err)
|
|
err = ds.SetSetupExperienceSoftwareTitles(ctx, "linux", team.ID, []uint{})
|
|
require.NoError(t, err)
|
|
|
|
linuxUUID := uuid.NewString()
|
|
_, err = ds.NewHost(ctx, &fleet.Host{
|
|
Hostname: "linux-host-" + linuxUUID,
|
|
UUID: linuxUUID,
|
|
Platform: "debian",
|
|
TeamID: &team.ID,
|
|
OsqueryHostID: new("oq-linux-" + linuxUUID),
|
|
NodeKey: new("nk-linux-" + linuxUUID),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
enrolled, err := ds.EnqueueSetupExperienceItems(ctx, "linux", "debian", linuxUUID, team.ID)
|
|
require.NoError(t, err)
|
|
assert.False(t, enrolled)
|
|
})
|
|
}
|
|
|
|
// testEnqueueSetupExperienceFirstAddedPerTitle verifies that when a title has more than one active
|
|
// package flagged for setup experience, only the first-added package is queued (no double-queue).
|
|
// testSetupExperienceCrossPlatformPyScripts guards the EnqueueSetupExperienceItems predicates
|
|
// (linux distro-agnostic clause + darwin cross-platform union) so they include .py, not just
|
|
// .sh — a .py installer is platform='linux' but runs on both Linux and macOS.
|
|
func testSetupExperienceCrossPlatformPyScripts(t *testing.T, ds *Datastore) {
|
|
ctx := context.Background()
|
|
|
|
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "team-cross-plat-py"})
|
|
require.NoError(t, err)
|
|
user := test.NewUser(t, ds, "Bob", "bob-py@example.com", true)
|
|
|
|
tfrPy, err := fleet.NewTempFileReader(strings.NewReader("#!/usr/bin/env python3\nprint('hello')"), t.TempDir)
|
|
require.NoError(t, err)
|
|
_, pyTitleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
|
InstallScript: "#!/usr/bin/env python3\nprint('install')",
|
|
InstallerFile: tfrPy,
|
|
StorageID: "storage-py-cross",
|
|
Filename: "cross.py",
|
|
Title: "Cross Platform Py Script",
|
|
Version: "1.0",
|
|
Source: "py_packages",
|
|
UserID: user.ID,
|
|
TeamID: &team.ID,
|
|
Platform: "linux",
|
|
Extension: "py",
|
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
t.Run("py appears in both linux and darwin listings", func(t *testing.T) {
|
|
for _, platform := range []string{"linux", "darwin"} {
|
|
titles, _, _, err := ds.ListSetupExperienceSoftwareTitles(ctx, platform, team.ID, fleet.ListOptions{})
|
|
require.NoError(t, err)
|
|
names := make([]string, 0, len(titles))
|
|
for _, tt := range titles {
|
|
if tt.SoftwarePackage != nil {
|
|
names = append(names, tt.SoftwarePackage.Name)
|
|
}
|
|
}
|
|
assert.Contains(t, names, "cross.py", "cross.py should be selectable for %s setup experience", platform)
|
|
}
|
|
})
|
|
|
|
t.Run("darwin host enqueues cross-selected .py", func(t *testing.T) {
|
|
err := ds.SetSetupExperienceSoftwareTitles(ctx, "darwin", team.ID, []uint{pyTitleID})
|
|
require.NoError(t, err)
|
|
|
|
darwinUUID := uuid.NewString()
|
|
_, err = ds.NewHost(ctx, &fleet.Host{
|
|
Hostname: "darwin-py-" + darwinUUID,
|
|
UUID: darwinUUID,
|
|
Platform: "darwin",
|
|
TeamID: &team.ID,
|
|
OsqueryHostID: new("oq-darwin-py-" + darwinUUID),
|
|
NodeKey: new("nk-darwin-py-" + darwinUUID),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
enrolled, err := ds.EnqueueSetupExperienceItems(ctx, "darwin", "darwin", darwinUUID, team.ID)
|
|
require.NoError(t, err)
|
|
assert.True(t, enrolled)
|
|
|
|
var names []string
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.SelectContext(ctx, q, &names,
|
|
`SELECT name FROM setup_experience_status_results WHERE host_uuid = ?`, darwinUUID)
|
|
})
|
|
assert.Contains(t, names, "Cross Platform Py Script", "darwin host should enqueue the cross-selected .py package")
|
|
})
|
|
|
|
t.Run("linux host enqueues native .py", func(t *testing.T) {
|
|
err := ds.SetSetupExperienceSoftwareTitles(ctx, "linux", team.ID, []uint{pyTitleID})
|
|
require.NoError(t, err)
|
|
|
|
linuxUUID := uuid.NewString()
|
|
_, err = ds.NewHost(ctx, &fleet.Host{
|
|
Hostname: "linux-py-" + linuxUUID,
|
|
UUID: linuxUUID,
|
|
Platform: "debian",
|
|
TeamID: &team.ID,
|
|
OsqueryHostID: new("oq-linux-py-" + linuxUUID),
|
|
NodeKey: new("nk-linux-py-" + linuxUUID),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
enrolled, err := ds.EnqueueSetupExperienceItems(ctx, "linux", "debian", linuxUUID, team.ID)
|
|
require.NoError(t, err)
|
|
assert.True(t, enrolled)
|
|
|
|
var names []string
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.SelectContext(ctx, q, &names,
|
|
`SELECT name FROM setup_experience_status_results WHERE host_uuid = ?`, linuxUUID)
|
|
})
|
|
assert.Contains(t, names, "Cross Platform Py Script", "linux host should enqueue the native .py package")
|
|
})
|
|
}
|
|
|
|
func testEnqueueSetupExperienceFirstAddedPerTitle(t *testing.T, ds *Datastore) {
|
|
ctx := context.Background()
|
|
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "se-multi-pkg"})
|
|
require.NoError(t, err)
|
|
user := test.NewUser(t, ds, "SE Admin", "se-admin@example.com", true)
|
|
|
|
newPkg := func(storage, filename string) uint {
|
|
tfr, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
|
|
require.NoError(t, err)
|
|
id, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
|
InstallScript: "install",
|
|
InstallerFile: tfr,
|
|
StorageID: storage,
|
|
Filename: filename,
|
|
Title: "MultiPkgTitle",
|
|
Version: "1.0",
|
|
Source: "apps",
|
|
BundleIdentifier: "com.example.multipkg",
|
|
UserID: user.ID,
|
|
TeamID: &team.ID,
|
|
Platform: string(fleet.MacOSPlatform),
|
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
|
})
|
|
require.NoError(t, err)
|
|
return id
|
|
}
|
|
|
|
// Two packages under the same title (same bundle id, different content hash), both flagged for setup.
|
|
firstAddedID := newPkg("storage-a", "pkgA.pkg")
|
|
secondID := newPkg("storage-b", "pkgB.pkg")
|
|
require.Less(t, firstAddedID, secondID)
|
|
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx, "UPDATE software_installers SET install_during_setup = 1 WHERE id IN (?, ?)", firstAddedID, secondID)
|
|
return err
|
|
})
|
|
|
|
hostUUID := "multi-pkg-host"
|
|
_, err = ds.NewHost(ctx, &fleet.Host{
|
|
Hostname: "macos-multi-pkg",
|
|
OsqueryHostID: new("osquery-multi-pkg"),
|
|
NodeKey: new("node-key-multi-pkg"),
|
|
UUID: hostUUID,
|
|
Platform: "darwin",
|
|
HardwareSerial: "multi-pkg-serial",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
assertSinglePackageQueued := func() {
|
|
results, err := ds.ListSetupExperienceResultsByHostUUID(ctx, hostUUID, team.ID)
|
|
require.NoError(t, err)
|
|
var installerResults []*fleet.SetupExperienceStatusResult
|
|
for _, r := range results {
|
|
if r.SoftwareInstallerID != nil {
|
|
installerResults = append(installerResults, r)
|
|
}
|
|
}
|
|
require.Len(t, installerResults, 1, "a multi-package title should queue exactly one package during setup")
|
|
require.Equal(t, firstAddedID, *installerResults[0].SoftwareInstallerID, "the first-added package should be queued")
|
|
}
|
|
|
|
enqueued, err := ds.EnqueueSetupExperienceItems(ctx, "darwin", "darwin", hostUUID, team.ID)
|
|
require.NoError(t, err)
|
|
require.True(t, enqueued)
|
|
assertSinglePackageQueued()
|
|
|
|
// Re-enqueue stays a single row (idempotent, still no double-queue).
|
|
enqueued, err = ds.EnqueueSetupExperienceItems(ctx, "darwin", "darwin", hostUUID, team.ID)
|
|
require.NoError(t, err)
|
|
require.True(t, enqueued)
|
|
assertSinglePackageQueued()
|
|
}
|