fleet/server/datastore/mysql/setup_experience_test.go
Jonathan Katz c2eb45f9a7
🤖 Fix GitOps leaving duplicate software installer rows (#43903)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #43738 

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

- [ ] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.
- [ ] Timeouts are implemented and retries are limited to avoid infinite
loops
- [ ] If paths of existing endpoints are modified without backwards
compatibility, checked the frontend/CLI for any necessary changes

## Testing

- [x] Added/updated automated tests
- [ ] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)

- [x] QA'd all new/changed functionality manually

- Before the fix, switching from custom package to FMA via GitOps
created two software installer rows and duplicate setup experience
installers (the setup experience page said "2 software items will be
installed during setup" even though only one was selected.
- After the fix, switching from custom package to FMA via GitOps deleted
the old installer and left only one row with the correct FMA. In setup
experience, only one instance of the software was installed.
- Added a custom package (obsidian) and a policy with a software install
automation for it, then applied gitops and replaced obsidian with the
FMA version and the policy with the FMA slug, and it redirected the
policy to the new installer.
- Adding setup experience software will only set
`install_during_setup=1` on the active FMA, and not on installer rows
with `is_active=0`
<img width="1222" height="558" alt="image"
src="https://github.com/user-attachments/assets/ace5922a-63ec-4591-b615-1a8534a70805"
/>
<img width="1173" height="483" alt="image"
src="https://github.com/user-attachments/assets/05c7c718-4f4a-4549-bbf1-1e1d6dae75d0"
/>


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Prevent duplicate installs by ensuring only active installers are
considered during setup; remove or replace custom installers when a
managed (fleet‑maintained) installer is added, repointing policies to
the active installer and canceling now-obsolete pending setup actions.
* **Tests**
* Added tests covering active-installer selection, custom→managed
installer replacement, policy repointing, display-name preservation, and
cancellation of pending setup activities.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-04-22 13:48:56 -04:00

2096 lines
80 KiB
Go

package mysql
import (
"context"
"database/sql"
"strings"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/fleet"
"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},
}
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)
}
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: ptr.Uint(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: ptr.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",
}
err = ds.SetSetupExperienceScript(ctx, initialScript1)
require.NoError(t, err)
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
err = ds.SetSetupExperienceScript(ctx, initialScript1)
require.NoError(t, err)
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
err = ds.SetSetupExperienceScript(ctx, updatedScript1)
require.NoError(t, err)
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")
}