From ce5f1e050a560e620a53811d8a9516ec6d06596d Mon Sep 17 00:00:00 2001 From: Jahziel Villasana-Espinoza Date: Fri, 13 Mar 2026 18:10:55 -0400 Subject: [PATCH] fix issue with duplicate entries in setup experience for FMAs (#41685) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **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. LWScreenShot 2026-03-13 at 2 53
50 PM --- changes/41663-duplicates | 1 + server/datastore/mysql/setup_experience.go | 3 +- .../integration_mdm_setup_experience_test.go | 342 ++++++++++++++++++ server/service/testing_utils.go | 5 +- 4 files changed, 349 insertions(+), 2 deletions(-) create mode 100644 changes/41663-duplicates diff --git a/changes/41663-duplicates b/changes/41663-duplicates new file mode 100644 index 0000000000..9f25aaaf69 --- /dev/null +++ b/changes/41663-duplicates @@ -0,0 +1 @@ +- Stopped duplicate Fleet-maintained app entries from showing up in setup experience. diff --git a/server/datastore/mysql/setup_experience.go b/server/datastore/mysql/setup_experience.go index a2e5f71750..2a02020215 100644 --- a/server/datastore/mysql/setup_experience.go +++ b/server/datastore/mysql/setup_experience.go @@ -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 diff --git a/server/service/integration_mdm_setup_experience_test.go b/server/service/integration_mdm_setup_experience_test.go index d84e79bfce..72b36b71d5 100644 --- a/server/service/integration_mdm_setup_experience_test.go +++ b/server/service/integration_mdm_setup_experience_test.go @@ -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() diff --git a/server/service/testing_utils.go b/server/service/testing_utils.go index 0ba66f6659..ed20281ed3 100644 --- a/server/service/testing_utils.go +++ b/server/service/testing_utils.go @@ -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