fix issue with duplicate entries in setup experience for FMAs (#41685)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #41663 

# 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.

- [x] 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

## Testing

- [x] Added/updated automated tests
- [x] 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

The 2 first software entries are for FMAs that had multiple versions in
Fleet and had been rolled back. Note that there is 1 row for each.

<img width="2940" height="1912" alt="LWScreenShot 2026-03-13 at 2 53
50 PM"
src="https://github.com/user-attachments/assets/48464655-5728-4965-8fd4-4c4c0c550f35"
/>
This commit is contained in:
Jahziel Villasana-Espinoza
2026-03-13 18:10:55 -04:00
committed by GitHub
parent 49a6598acd
commit ce5f1e050a
4 changed files with 349 additions and 2 deletions
+1
View File
@@ -0,0 +1 @@
- Stopped duplicate Fleet-maintained app entries from showing up in setup experience.
+2 -1
View File
@@ -85,6 +85,7 @@ INNER JOIN software_titles st
ON si.title_id = st.id
WHERE install_during_setup = true
AND global_or_team_id = ?
AND si.is_active = TRUE
AND (
-- installer platform matches the host's fleet platform (darwin, linux or windows)
si.platform = ?
@@ -535,7 +536,7 @@ SELECT
END AS error
FROM setup_experience_status_results sesr
LEFT JOIN setup_experience_scripts ses ON ses.id = sesr.setup_experience_script_id
LEFT JOIN software_installers si ON si.id = sesr.software_installer_id
LEFT JOIN software_installers si ON si.id = sesr.software_installer_id AND si.is_active = TRUE
LEFT JOIN host_software_installs hsi ON hsi.execution_id = sesr.host_software_installs_execution_id
LEFT JOIN host_script_results hsr ON hsr.execution_id = sesr.script_execution_id
LEFT JOIN vpp_apps_teams vat ON vat.id = sesr.vpp_app_team_id
@@ -579,6 +579,348 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithSoftwareAndScriptAu
s.lastActivityMatches(fleet.ActivityTypeRanScript{}.ActivityName(), expectedActivityDetail, 0)
}
// TestSetupExperienceFlowWithFMAAndVersionRollback tests the full setup
// experience flow using a Fleet Maintained App (FMA) as the software to
// install, and exercises the FMA version rollback functionality: the FMA is
// added at v1.0, upgraded to v2.0 (so both are cached), then rolled back to
// v1.0 via the batch-set endpoint. The setup experience is then driven to
// completion using the rolled-back installer and the device is auto-released.
func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithFMAAndVersionRollback() {
t := s.T()
ctx := context.Background()
s.setSkipWorkerJobs(t)
// -------------------------------------------------------------------------
// Set up manifest + installer mock servers for a darwin FMA using the
// shared startFMAServers helper.
// We use dummy_installer.pkg as the on-disk bytes so the server can parse
// it as a real macOS pkg. The SHA is computed by fmaTestState.ComputeSHA.
// -------------------------------------------------------------------------
pkgBytes, err := os.ReadFile("testdata/software-installers/dummy_installer.pkg")
require.NoError(t, err)
// v2 uses slightly different bytes so it gets a distinct SHA and storage ID.
v2Bytes := fmt.Append(pkgBytes, []byte("v2"))
// fmaState is the single mutable state object the manifest server reads.
// startFMAServers calls ComputeSHA on the initial installerBytes.
fmaState := &fmaTestState{
version: "1.0",
installerBytes: pkgBytes,
installerPath: "/1password.pkg",
}
startFMAServers(t, s.ds, map[string]*fmaTestState{
"/1password/darwin.json": fmaState,
})
// -------------------------------------------------------------------------
// Helper: issue a batch-set request and wait for completion.
// -------------------------------------------------------------------------
batchSet := func(tm fleet.Team, software []*fleet.SoftwareInstallerPayload) []fleet.SoftwarePackageResponse {
var resp batchSetSoftwareInstallersResponse
s.DoJSON("POST", "/api/latest/fleet/software/batch",
batchSetSoftwareInstallersRequest{Software: software, TeamName: tm.Name},
http.StatusAccepted, &resp,
"team_name", tm.Name, "team_id", fmt.Sprint(tm.ID),
)
return waitBatchSetSoftwareInstallersCompleted(t, &s.withServer, tm.Name, resp.RequestUUID)
}
// -------------------------------------------------------------------------
// Create the team and DEP-enroll a device into it (same as the Auto-Release
// test, but we inject the FMA instead of a custom .pkg).
// -------------------------------------------------------------------------
s.enableABM("fleet-setup-experience-fma")
tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "-team"})
require.NoError(t, err)
teamDevice := godep.Device{
SerialNumber: uuid.New().String(),
Model: "MacBook Pro",
OS: "osx",
OpType: "added",
}
// Add a team MDM profile so we can assert on the profile install commands.
teamProfile := mobileconfigForTest("N1", "I1")
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{Profiles: [][]byte{teamProfile}},
http.StatusNoContent, "team_id", fmt.Sprint(tm.ID))
// -------------------------------------------------------------------------
// Section 1: add the FMA at v1.0 via batch-set, then upgrade to v2.0 so
// that two versions are cached, then roll back to v1.0.
// -------------------------------------------------------------------------
// Add v1.0.
packages := batchSet(*tm, []*fleet.SoftwareInstallerPayload{
{Slug: ptr.String("1password/darwin")},
})
require.Len(t, packages, 1)
require.NotNil(t, packages[0].TitleID)
fmaTitleID := *packages[0].TitleID
// Verify the active version is v1.0.
var titlesResp listSoftwareTitlesResponse
s.DoJSON("GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{}, http.StatusOK, &titlesResp,
"available_for_install", "true",
"team_id", fmt.Sprint(tm.ID),
)
require.Len(t, titlesResp.SoftwareTitles, 1)
require.Equal(t, "1.0", titlesResp.SoftwareTitles[0].SoftwarePackage.Version)
require.Len(t, titlesResp.SoftwareTitles[0].SoftwarePackage.FleetMaintainedVersions, 1)
// Advance to v2.0 — both v1 and v2 are now cached.
fmaState.version = "2.0"
fmaState.installerBytes = v2Bytes
fmaState.ComputeSHA(v2Bytes)
packages = batchSet(*tm, []*fleet.SoftwareInstallerPayload{
{Slug: ptr.String("1password/darwin")},
})
require.Len(t, packages, 2, "both v1.0 and v2.0 should be cached")
titlesResp = listSoftwareTitlesResponse{}
s.DoJSON("GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{}, http.StatusOK, &titlesResp,
"available_for_install", "true",
"team_id", fmt.Sprint(tm.ID),
)
require.Len(t, titlesResp.SoftwareTitles, 1)
require.Equal(t, "2.0", titlesResp.SoftwareTitles[0].SoftwarePackage.Version)
require.Len(t, titlesResp.SoftwareTitles[0].SoftwarePackage.FleetMaintainedVersions, 2)
// Roll back to v1.0 by specifying RollbackVersion in the batch request
// (simulating a GitOps yaml that pins fleet_maintained_app_version: "1.0").
// The manifest server still advertises v2.0, but the rollback tells the
// batch-set endpoint to activate the already-cached v1.0 installer instead
// of downloading again.
packages = batchSet(*tm, []*fleet.SoftwareInstallerPayload{
{Slug: ptr.String("1password/darwin"), RollbackVersion: "1.0"},
})
require.Len(t, packages, 2, "both versions should still be cached after rollback")
titlesResp = listSoftwareTitlesResponse{}
s.DoJSON("GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{}, http.StatusOK, &titlesResp,
"available_for_install", "true",
"team_id", fmt.Sprint(tm.ID),
)
require.Len(t, titlesResp.SoftwareTitles, 1)
require.Equal(t, "1.0", titlesResp.SoftwareTitles[0].SoftwarePackage.Version,
"active version must be v1.0 after rollback")
// -------------------------------------------------------------------------
// Section 2: configure setup experience to install the (rolled-back) FMA,
// then drive a full DEP enrollment through to auto-release.
// -------------------------------------------------------------------------
// Mark the FMA title as a setup experience install.
var swInstallResp putSetupExperienceSoftwareResponse
s.DoJSON("PUT", "/api/v1/fleet/setup_experience/software",
putSetupExperienceSoftwareRequest{TeamID: tm.ID, TitleIDs: []uint{fmaTitleID}},
http.StatusOK, &swInstallResp)
s.lastActivityOfTypeMatches(fleet.ActivityEditedSetupExperienceSoftware{}.ActivityName(),
fmt.Sprintf(`{"platform": "darwin", "fleet_id": %d, "fleet_name": "%s", "team_id": %d, "team_name": "%s"}`,
tm.ID, tm.Name, tm.ID, tm.Name), 0)
s.pushProvider.PushFunc = func(_ context.Context, pushes []*mdm.Push) (map[string]*push.Response, error) {
return map[string]*push.Response{}, nil
}
s.mockDEPResponse("fleet-setup-experience-fma", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
encoder := json.NewEncoder(w)
switch r.URL.Path {
case "/session":
require.NoError(t, encoder.Encode(map[string]string{"auth_session_token": "xyz"}))
case "/profile":
require.NoError(t, encoder.Encode(godep.ProfileResponse{ProfileUUID: uuid.New().String()}))
case "/server/devices":
require.NoError(t, encoder.Encode(godep.DeviceResponse{Devices: []godep.Device{teamDevice}}))
case "/devices/sync":
require.NoError(t, encoder.Encode(godep.DeviceResponse{Devices: []godep.Device{teamDevice}, Cursor: "foo"}))
case "/profile/devices":
b, err := io.ReadAll(r.Body)
require.NoError(t, err)
var prof profileAssignmentReq
require.NoError(t, json.Unmarshal(b, &prof))
resp := godep.ProfileResponse{ProfileUUID: prof.ProfileUUID}
resp.Devices = make(map[string]string, len(prof.Devices))
for _, d := range prof.Devices {
resp.Devices[d] = string(fleet.DEPAssignProfileResponseSuccess)
}
require.NoError(t, encoder.Encode(resp))
default:
_, _ = w.Write([]byte(`{}`))
}
}))
s.runDEPSchedule()
listHostsRes := listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
require.Len(t, listHostsRes.Hosts, 1)
require.Equal(t, teamDevice.SerialNumber, listHostsRes.Hosts[0].HardwareSerial)
enrolledHost := listHostsRes.Hosts[0].Host
enrolledHost.TeamID = &tm.ID
s.Do("POST", "/api/v1/fleet/hosts/transfer",
addHostsToTeamRequest{TeamID: &tm.ID, HostIDs: []uint{enrolledHost.ID}}, http.StatusOK)
// DEP enroll the MDM device.
depURLToken := loadEnrollmentProfileDEPToken(t, s.ds)
mdmDevice := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, depURLToken)
mdmDevice.SerialNumber = teamDevice.SerialNumber
require.NoError(t, mdmDevice.Enroll())
s.runWorker()
s.awaitTriggerProfileSchedule(t)
// Drain the initial MDM commands (InstallProfile × 3 + InstallEnterpriseApplication × 1).
var cmds []*micromdm.CommandPayload
cmd, err := mdmDevice.Idle()
require.NoError(t, err)
for cmd != nil {
var fullCmd micromdm.CommandPayload
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
cmds = append(cmds, &fullCmd)
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
}
require.Len(t, cmds, 4) // 3 InstallProfile + 1 InstallEnterpriseApplication (fleetd)
// Orbit-enroll the host (simulates fleetd being installed).
enrolledHost.OsqueryHostID = ptr.String(mdmDevice.UUID)
enrolledHost.UUID = mdmDevice.UUID
orbitKey := setOrbitEnrollment(t, enrolledHost, s.ds)
enrolledHost.OrbitNodeKey = &orbitKey
// No pending Release Device job yet.
pending, err := s.ds.GetQueuedJobs(ctx, 1, time.Now().UTC().Add(time.Minute))
require.NoError(t, err)
require.Len(t, pending, 0)
// First /status call: software pending, no script involved.
var statusResp getOrbitSetupExperienceStatusResponse
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status",
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *enrolledHost.OrbitNodeKey)),
http.StatusOK, &statusResp)
require.Nil(t, statusResp.Results.BootstrapPackage)
require.Nil(t, statusResp.Results.AccountConfiguration)
require.Len(t, statusResp.Results.ConfigurationProfiles, 3)
require.Nil(t, statusResp.Results.Script)
require.Len(t, statusResp.Results.Software, 1)
fmaResult := statusResp.Results.Software[0]
require.Equal(t, "1Password", fmaResult.Name)
require.Equal(t, fleet.SetupExperienceStatusPending, fmaResult.Status)
require.NotNil(t, fmaResult.SoftwareTitleID)
require.Equal(t, fmaTitleID, *fmaResult.SoftwareTitleID)
// Pull the execution ID out of the DB (the status endpoint doesn't surface it).
results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, enrolledHost.UUID)
require.NoError(t, err)
require.Len(t, results, 1)
require.NotNil(t, results[0].HostSoftwareInstallsExecutionID)
installUUID := *results[0].HostSoftwareInstallsExecutionID
require.NotEmpty(t, installUUID)
// Retrieve the title so we can read the active package name (should be v1.0).
var titleDetail getSoftwareTitleResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", fmaTitleID),
nil, http.StatusOK, &titleDetail,
"team_id", fmt.Sprint(tm.ID))
require.NotNil(t, titleDetail.SoftwareTitle)
require.NotNil(t, titleDetail.SoftwareTitle.SoftwarePackage)
require.Equal(t, "1.0", titleDetail.SoftwareTitle.SoftwarePackage.Version,
"the installed version during setup experience must be the rolled-back v1.0")
// No MDM command was enqueued just from the /status call (device not released yet).
cmd, err = mdmDevice.Idle()
require.NoError(t, err)
require.Nil(t, cmd)
// Second /status call: software transitions to running.
statusResp = getOrbitSetupExperienceStatusResponse{}
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status",
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *enrolledHost.OrbitNodeKey)),
http.StatusOK, &statusResp)
require.Len(t, statusResp.Results.Software, 1)
require.Equal(t, "1Password", statusResp.Results.Software[0].Name)
require.Equal(t, fleet.SetupExperienceStatusRunning, statusResp.Results.Software[0].Status)
// Verify the upcoming activity references the rolled-back v1.0 package.
var hostActivitiesResp listHostUpcomingActivitiesResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", enrolledHost.ID),
nil, http.StatusOK, &hostActivitiesResp)
require.Len(t, hostActivitiesResp.Activities, 1)
require.NotNil(t, hostActivitiesResp.Activities[0].Details)
var activityDetails map[string]any
require.NoError(t, json.Unmarshal(*hostActivitiesResp.Activities[0].Details, &activityDetails))
require.Equal(t, installUUID, activityDetails["install_uuid"])
require.Equal(t, "1Password", activityDetails["software_title"])
// The package name must come from the v1.0 installer, not v2.0.
require.Equal(t, titleDetail.SoftwareTitle.SoftwarePackage.Name, activityDetails["software_package"])
// Post a successful install result for the FMA.
s.Do("POST", "/api/fleet/orbit/software_install/result",
json.RawMessage(fmt.Sprintf(`{
"orbit_node_key": %q,
"install_uuid": %q,
"install_script_exit_code": 0,
"install_script_output": "ok"
}`, *enrolledHost.OrbitNodeKey, installUUID)), http.StatusNoContent)
// /status call after success: software is now complete.
statusResp = getOrbitSetupExperienceStatusResponse{}
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status",
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *enrolledHost.OrbitNodeKey)),
http.StatusOK, &statusResp)
require.Nil(t, statusResp.Results.BootstrapPackage)
require.Nil(t, statusResp.Results.AccountConfiguration)
require.Nil(t, statusResp.Results.Script)
require.Len(t, statusResp.Results.Software, 1)
require.Equal(t, "1Password", statusResp.Results.Software[0].Name)
require.Equal(t, fleet.SetupExperienceStatusSuccess, statusResp.Results.Software[0].Status)
// The device should now receive a DeviceConfigured command (auto-release).
cmd, err = mdmDevice.Idle()
require.NoError(t, err)
cmds = cmds[:0]
for cmd != nil {
var fullCmd micromdm.CommandPayload
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
cmds = append(cmds, &fullCmd)
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
}
require.Len(t, cmds, 1)
require.Equal(t, "DeviceConfigured", cmds[0].Command.RequestType)
// Verify the installed-software activity references the rolled-back v1.0 package.
var getHostResp getHostResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", enrolledHost.ID), nil, http.StatusOK, &getHostResp)
expectedActivityDetail := fmt.Sprintf(`
{
"host_id": %d,
"host_display_name": "%s",
"software_title": "1Password",
"software_package": "%s",
"self_service": false,
"install_uuid": "%s",
"status": "installed",
"source": "apps",
"policy_id": null,
"policy_name": null
}
`, enrolledHost.ID, getHostResp.Host.DisplayName, titleDetail.SoftwareTitle.SoftwarePackage.Name, installUUID)
s.lastActivityMatchesExtended(fleet.ActivityTypeInstalledSoftware{}.ActivityName(), expectedActivityDetail, 0, ptr.Bool(true))
}
func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithSoftwareAndScriptForceRelease() {
t := s.T()
ctx := context.Background()
+4 -1
View File
@@ -1464,7 +1464,10 @@ func startFMAServers(t *testing.T, ds fleet.Datastore, states map[string]*fmaTes
_, _ = w.Write(state.installerBytes)
}))
maintained_apps.SyncApps(t, ds)
// call Refresh directly (instead of SyncApps) since we're using the server above and not the file server
// created in SyncApps
err := maintained_apps.Refresh(t.Context(), ds, slog.New(slog.DiscardHandler))
require.NoError(t, err)
manifestServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var state *fmaTestState