mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #41741 # 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, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters. ## 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 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Software setup items are now ordered using custom display names when available. * **Bug Fixes** * Software installations now process sequentially for improved reliability and predictability. * Enhanced handling of missing installation tracking data to prevent failures. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Ian Littman <iansltx@gmail.com>
5126 lines
221 KiB
Go
5126 lines
221 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"os"
|
||
"slices"
|
||
"sort"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/fleetdm/fleet/v4/pkg/mdm/mdmtest"
|
||
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
|
||
"github.com/fleetdm/fleet/v4/server/dev_mode"
|
||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||
"github.com/fleetdm/fleet/v4/server/mdm/android"
|
||
android_service "github.com/fleetdm/fleet/v4/server/mdm/android/service"
|
||
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
|
||
"github.com/fleetdm/fleet/v4/server/mdm/apple/vpp"
|
||
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
|
||
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
|
||
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push"
|
||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||
"github.com/fleetdm/fleet/v4/server/worker"
|
||
"github.com/google/uuid"
|
||
"github.com/jmoiron/sqlx"
|
||
micromdm "github.com/micromdm/micromdm/mdm/mdm"
|
||
"github.com/micromdm/plist"
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
"google.golang.org/api/androidmanagement/v1"
|
||
"google.golang.org/api/googleapi"
|
||
)
|
||
|
||
func (s *integrationMDMTestSuite) TestSetupExperienceScript() {
|
||
t := s.T()
|
||
|
||
tm, err := s.ds.NewTeam(context.Background(), &fleet.Team{
|
||
Name: t.Name(),
|
||
Description: "desc",
|
||
})
|
||
require.NoError(t, err)
|
||
|
||
// create new team script
|
||
var newScriptResp setSetupExperienceScriptResponse
|
||
body, headers := generateNewScriptMultipartRequest(t,
|
||
"script42.sh", []byte(`echo "hello"`), s.token, map[string][]string{"team_id": {fmt.Sprintf("%d", tm.ID)}})
|
||
res := s.DoRawWithHeaders("POST", "/api/latest/fleet/setup_experience/script", body.Bytes(), http.StatusOK, headers)
|
||
err = json.NewDecoder(res.Body).Decode(&newScriptResp)
|
||
require.NoError(t, err)
|
||
|
||
// test script secret validation
|
||
body, headers = generateNewScriptMultipartRequest(t,
|
||
"script.sh", []byte(`echo "$FLEET_SECRET_INVALID"`), s.token, map[string][]string{})
|
||
s.DoRawWithHeaders("POST", "/api/latest/fleet/setup_experience/script", body.Bytes(), http.StatusUnprocessableEntity, headers)
|
||
|
||
// get team script metadata
|
||
var getScriptResp getSetupExperienceScriptResponse
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/setup_experience/script?team_id=%d", tm.ID), nil, http.StatusOK, &getScriptResp)
|
||
require.Equal(t, "script42.sh", getScriptResp.Name)
|
||
require.NotNil(t, getScriptResp.TeamID)
|
||
require.Equal(t, tm.ID, *getScriptResp.TeamID)
|
||
require.NotZero(t, getScriptResp.ID)
|
||
require.NotZero(t, getScriptResp.CreatedAt)
|
||
require.NotZero(t, getScriptResp.UpdatedAt)
|
||
|
||
// get team script contents
|
||
res = s.Do("GET", fmt.Sprintf("/api/latest/fleet/setup_experience/script?team_id=%d&alt=media", tm.ID), nil, http.StatusOK)
|
||
b, err := io.ReadAll(res.Body)
|
||
require.NoError(t, err)
|
||
require.Equal(t, `echo "hello"`, string(b))
|
||
require.Equal(t, int64(len(`echo "hello"`)), res.ContentLength)
|
||
require.Equal(t, fmt.Sprintf("attachment;filename=\"%s %s\"", time.Now().Format(time.DateOnly), "script42.sh"), res.Header.Get("Content-Disposition"))
|
||
|
||
// try to update script with same name, should not fail because this is allowed
|
||
body, headers = generateNewScriptMultipartRequest(t,
|
||
"script42.sh", []byte(`echo "hello"`), s.token, map[string][]string{"team_id": {fmt.Sprintf("%d", tm.ID)}})
|
||
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/setup_experience/script", body.Bytes(), http.StatusOK, headers)
|
||
err = json.NewDecoder(res.Body).Decode(&newScriptResp)
|
||
require.NoError(t, err)
|
||
|
||
// update with a different name and contents via PUT endpoint, should suceed
|
||
body, headers = generateNewScriptMultipartRequest(t,
|
||
"different.sh", []byte(`echo "hello2"`), s.token, map[string][]string{"team_id": {fmt.Sprintf("%d", tm.ID)}})
|
||
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/setup_experience/script", body.Bytes(), http.StatusOK, headers)
|
||
err = json.NewDecoder(res.Body).Decode(&newScriptResp)
|
||
require.NoError(t, err)
|
||
|
||
// create no-team script
|
||
body, headers = generateNewScriptMultipartRequest(t,
|
||
"script42.sh", []byte(`echo "hello"`), s.token, nil)
|
||
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/setup_experience/script", body.Bytes(), http.StatusOK, headers)
|
||
err = json.NewDecoder(res.Body).Decode(&newScriptResp)
|
||
require.NoError(t, err)
|
||
// // TODO: confirm if we will allow team_id=0 requests
|
||
// noTeamID := uint(0) // TODO: confirm if we will allow team_id=0 requests
|
||
// body, headers = generateNewScriptMultipartRequest(t,
|
||
// "script42.sh", []byte(`echo "hello"`), s.token, map[string][]string{"team_id": {fmt.Sprintf("%d", noTeamID)}})
|
||
|
||
// get no-team script metadata
|
||
s.DoJSON("GET", "/api/latest/fleet/setup_experience/script", nil, http.StatusOK, &getScriptResp)
|
||
require.Equal(t, "script42.sh", getScriptResp.Name)
|
||
require.Nil(t, getScriptResp.TeamID)
|
||
require.NotZero(t, getScriptResp.ID)
|
||
require.NotZero(t, getScriptResp.CreatedAt)
|
||
require.NotZero(t, getScriptResp.UpdatedAt)
|
||
// // TODO: confirm if we will allow team_id=0 requests
|
||
// s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/setup_experience/script?team_id=%d", noTeamID), nil, http.StatusOK, &getScriptResp)
|
||
|
||
// get no-team script contents
|
||
res = s.Do("GET", "/api/latest/fleet/setup_experience/script?alt=media", nil, http.StatusOK)
|
||
b, err = io.ReadAll(res.Body)
|
||
require.NoError(t, err)
|
||
require.Equal(t, `echo "hello"`, string(b))
|
||
require.Equal(t, int64(len(`echo "hello"`)), res.ContentLength)
|
||
require.Equal(t, fmt.Sprintf("attachment;filename=\"%s %s\"", time.Now().Format(time.DateOnly), "script42.sh"), res.Header.Get("Content-Disposition"))
|
||
// // TODO: confirm if we will allow team_id=0 requests
|
||
// res = s.Do("GET", fmt.Sprintf("/api/latest/fleet/setup_experience/script?team_id=%d&alt=media", noTeamID), nil, http.StatusOK)
|
||
|
||
// delete the no-team script
|
||
s.Do("DELETE", "/api/latest/fleet/setup_experience/script", nil, http.StatusOK)
|
||
|
||
// try get the no-team script
|
||
s.Do("GET", "/api/latest/fleet/setup_experience/script", nil, http.StatusNotFound)
|
||
|
||
// try deleting the no-team script again
|
||
s.Do("DELETE", "/api/latest/fleet/setup_experience/script", nil, http.StatusOK) // TODO: confirm if we want to return not found
|
||
|
||
// // TODO: confirm if we will allow team_id=0 requests
|
||
// s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/setup_experience/script/?team_id=%d", noTeamID), nil, http.StatusOK)
|
||
|
||
// delete the team script
|
||
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/setup_experience/script?team_id=%d", tm.ID), nil, http.StatusOK)
|
||
|
||
// try get the team script
|
||
s.Do("GET", fmt.Sprintf("/api/latest/fleet/setup_experience/script?team_id=%d", tm.ID), nil, http.StatusNotFound)
|
||
|
||
// try deleting the team script again
|
||
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/setup_experience/script?team_id=%d", tm.ID), nil, http.StatusOK) // TODO: confirm if we want to return not found
|
||
}
|
||
|
||
func (s *integrationMDMTestSuite) createTeamDeviceForSetupExperienceWithProfileSoftwareAndScript() (device godep.Device, host *fleet.Host, tm *fleet.Team) {
|
||
t := s.T()
|
||
ctx := context.Background()
|
||
|
||
// enroll a device in a team with software to install and a script to execute
|
||
s.enableABM("fleet-setup-experience")
|
||
tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team 1"})
|
||
require.NoError(t, err)
|
||
|
||
teamDevice := godep.Device{SerialNumber: uuid.New().String(), Model: "MacBook Pro", OS: "osx", OpType: "added"}
|
||
|
||
// add a team profile
|
||
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))
|
||
|
||
// add a macOS software to install
|
||
payloadDummy := &fleet.UploadSoftwareInstallerPayload{
|
||
InstallScript: "install",
|
||
Filename: "dummy_installer.pkg",
|
||
Title: "DummyApp",
|
||
TeamID: &tm.ID,
|
||
}
|
||
s.uploadSoftwareInstaller(t, payloadDummy, http.StatusOK, "")
|
||
titleID := getSoftwareTitleID(t, s.ds, payloadDummy.Title, "apps")
|
||
var swInstallResp putSetupExperienceSoftwareResponse
|
||
s.DoJSON("PUT", "/api/v1/fleet/setup_experience/software", putSetupExperienceSoftwareRequest{TeamID: tm.ID, TitleIDs: []uint{titleID}}, 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)
|
||
|
||
// add a script to execute
|
||
body, headers := generateNewScriptMultipartRequest(t,
|
||
"script.sh", []byte(`echo "hello"`), s.token, map[string][]string{"team_id": {fmt.Sprintf("%d", tm.ID)}})
|
||
s.DoRawWithHeaders("POST", "/api/latest/fleet/setup_experience/script", body.Bytes(), http.StatusOK, headers)
|
||
|
||
// no bootstrap package, no custom setup assistant (those are already tested
|
||
// in the DEPEnrollReleaseDevice tests).
|
||
|
||
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", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
encoder := json.NewEncoder(w)
|
||
switch r.URL.Path {
|
||
case "/session":
|
||
err := encoder.Encode(map[string]string{"auth_session_token": "xyz"})
|
||
require.NoError(t, err)
|
||
case "/profile":
|
||
err := encoder.Encode(godep.ProfileResponse{ProfileUUID: uuid.New().String()})
|
||
require.NoError(t, err)
|
||
case "/server/devices":
|
||
err := encoder.Encode(godep.DeviceResponse{Devices: []godep.Device{teamDevice}})
|
||
require.NoError(t, err)
|
||
case "/devices/sync":
|
||
// This endpoint is polled over time to sync devices from
|
||
// ABM, send a repeated serial
|
||
err := encoder.Encode(godep.DeviceResponse{Devices: []godep.Device{teamDevice}, Cursor: "foo"})
|
||
require.NoError(t, err)
|
||
case "/profile/devices":
|
||
b, err := io.ReadAll(r.Body)
|
||
require.NoError(t, err)
|
||
|
||
var prof profileAssignmentReq
|
||
require.NoError(t, json.Unmarshal(b, &prof))
|
||
|
||
var resp godep.ProfileResponse
|
||
resp.ProfileUUID = prof.ProfileUUID
|
||
resp.Devices = make(map[string]string, len(prof.Devices))
|
||
for _, device := range prof.Devices {
|
||
resp.Devices[device] = string(fleet.DEPAssignProfileResponseSuccess)
|
||
}
|
||
err = encoder.Encode(resp)
|
||
require.NoError(t, err)
|
||
default:
|
||
_, _ = w.Write([]byte(`{}`))
|
||
}
|
||
}))
|
||
|
||
// trigger a profile sync
|
||
s.runDEPSchedule()
|
||
|
||
// the (ghost) host now exists
|
||
listHostsRes := listHostsResponse{}
|
||
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
|
||
require.Len(t, listHostsRes.Hosts, 1)
|
||
require.Equal(t, listHostsRes.Hosts[0].HardwareSerial, teamDevice.SerialNumber)
|
||
enrolledHost := listHostsRes.Hosts[0].Host
|
||
enrolledHost.TeamID = &tm.ID
|
||
|
||
// transfer it to the team
|
||
s.Do("POST", "/api/v1/fleet/hosts/transfer",
|
||
addHostsToTeamRequest{TeamID: &tm.ID, HostIDs: []uint{enrolledHost.ID}}, http.StatusOK)
|
||
|
||
return teamDevice, enrolledHost, tm
|
||
}
|
||
|
||
func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithSoftwareAndScriptAutoRelease() {
|
||
t := s.T()
|
||
ctx := context.Background()
|
||
s.setSkipWorkerJobs(t)
|
||
|
||
teamDevice, enrolledHost, _ := s.createTeamDeviceForSetupExperienceWithProfileSoftwareAndScript()
|
||
|
||
// enroll the host
|
||
depURLToken := loadEnrollmentProfileDEPToken(t, s.ds)
|
||
mdmDevice := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, depURLToken)
|
||
mdmDevice.SerialNumber = teamDevice.SerialNumber
|
||
err := mdmDevice.Enroll()
|
||
require.NoError(t, err)
|
||
|
||
// ensure fleet profiles
|
||
s.awaitTriggerProfileSchedule(t)
|
||
// run the worker to process the DEP enroll request
|
||
s.awaitRunAppleMDMWorkerSchedule()
|
||
s.runWorker()
|
||
|
||
var cmds []*micromdm.CommandPayload
|
||
cmd, err := mdmDevice.Idle()
|
||
require.NoError(t, err)
|
||
for cmd != nil {
|
||
if cmd.Command.RequestType == "DeclarativeManagement" {
|
||
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
|
||
require.NoError(t, err)
|
||
continue
|
||
}
|
||
|
||
var fullCmd micromdm.CommandPayload
|
||
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
|
||
|
||
// Can be useful for debugging
|
||
// switch cmd.Command.RequestType {
|
||
// case "InstallProfile":
|
||
// fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType, string(fullCmd.Command.InstallProfile.Payload))
|
||
// case "InstallEnterpriseApplication":
|
||
// if fullCmd.Command.InstallEnterpriseApplication.ManifestURL != nil {
|
||
// fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType, *fullCmd.Command.InstallEnterpriseApplication.ManifestURL)
|
||
// } else {
|
||
// fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType)
|
||
// }
|
||
// default:
|
||
// fmt.Println(">>>> device received command: ", cmd.Command.RequestType)
|
||
// }
|
||
|
||
cmds = append(cmds, &fullCmd)
|
||
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
|
||
require.NoError(t, err)
|
||
}
|
||
|
||
// expected commands: install fleetd (install enterprise), install profiles
|
||
// (custom one, fleetd configuration, fleet CA root)
|
||
require.Len(t, cmds, 4)
|
||
var installProfileCount, installEnterpriseCount, otherCount int
|
||
var profileCustomSeen, profileFleetdSeen, profileFleetCASeen, profileFileVaultSeen bool
|
||
for _, cmd := range cmds {
|
||
switch cmd.Command.RequestType {
|
||
case "InstallProfile":
|
||
installProfileCount++
|
||
switch {
|
||
case strings.Contains(string(cmd.Command.InstallProfile.Payload), "<string>I1</string>"):
|
||
profileCustomSeen = true
|
||
case strings.Contains(string(cmd.Command.InstallProfile.Payload), fmt.Sprintf("<string>%s</string>", mobileconfig.FleetdConfigPayloadIdentifier)):
|
||
profileFleetdSeen = true
|
||
case strings.Contains(string(cmd.Command.InstallProfile.Payload), fmt.Sprintf("<string>%s</string>", mobileconfig.FleetCARootConfigPayloadIdentifier)):
|
||
profileFleetCASeen = true
|
||
case strings.Contains(string(cmd.Command.InstallProfile.Payload), fmt.Sprintf("<string>%s</string", mobileconfig.FleetFileVaultPayloadIdentifier)) &&
|
||
strings.Contains(string(cmd.Command.InstallProfile.Payload), "ForceEnableInSetupAssistant"):
|
||
profileFileVaultSeen = true
|
||
}
|
||
|
||
case "InstallEnterpriseApplication":
|
||
installEnterpriseCount++
|
||
default:
|
||
otherCount++
|
||
}
|
||
}
|
||
require.Equal(t, 3, installProfileCount)
|
||
require.Equal(t, 1, installEnterpriseCount)
|
||
require.Equal(t, 0, otherCount)
|
||
require.True(t, profileCustomSeen)
|
||
require.True(t, profileFleetdSeen)
|
||
require.True(t, profileFleetCASeen)
|
||
require.False(t, profileFileVaultSeen)
|
||
|
||
// simulate fleetd being installed and the host being orbit-enrolled now
|
||
enrolledHost.OsqueryHostID = ptr.String(mdmDevice.UUID)
|
||
enrolledHost.UUID = mdmDevice.UUID
|
||
orbitKey := setOrbitEnrollment(t, enrolledHost, s.ds)
|
||
enrolledHost.OrbitNodeKey = &orbitKey
|
||
|
||
// there shouldn't be a worker Release Device pending job (we don't release that way anymore)
|
||
pending, err := s.ds.GetQueuedJobs(ctx, 1, time.Now().UTC().Add(time.Minute))
|
||
require.NoError(t, err)
|
||
require.Len(t, pending, 0)
|
||
|
||
// call the /status endpoint, the software and script should be pending
|
||
var statusResp fleet.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) // no bootstrap package involved
|
||
require.Nil(t, statusResp.Results.AccountConfiguration) // no SSO involved
|
||
require.Len(t, statusResp.Results.ConfigurationProfiles, 3) // fleetd config, root CA, custom profile
|
||
var profNames []string
|
||
var profStatuses []fleet.MDMDeliveryStatus
|
||
for _, prof := range statusResp.Results.ConfigurationProfiles {
|
||
profNames = append(profNames, prof.Name)
|
||
profStatuses = append(profStatuses, prof.Status)
|
||
}
|
||
require.ElementsMatch(t, []string{"N1", "Fleetd configuration", "Fleet root certificate authority (CA)"}, profNames)
|
||
require.ElementsMatch(t, []fleet.MDMDeliveryStatus{fleet.MDMDeliveryVerifying, fleet.MDMDeliveryVerifying, fleet.MDMDeliveryVerifying}, profStatuses)
|
||
|
||
// the software and script are still pending
|
||
require.NotNil(t, statusResp.Results.Script)
|
||
require.Equal(t, "script.sh", statusResp.Results.Script.Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Script.Status)
|
||
require.Len(t, statusResp.Results.Software, 1)
|
||
require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Software[0].Status)
|
||
require.NotNil(t, statusResp.Results.Software[0].SoftwareTitleID)
|
||
require.NotZero(t, *statusResp.Results.Software[0].SoftwareTitleID)
|
||
|
||
// The /setup_experience/status endpoint doesn't return the various IDs for executions, so pull
|
||
// it out manually
|
||
results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, enrolledHost.UUID, *enrolledHost.TeamID)
|
||
require.NoError(t, err)
|
||
require.Len(t, results, 2)
|
||
var installUUID string
|
||
for _, r := range results {
|
||
if r.HostSoftwareInstallsExecutionID != nil {
|
||
installUUID = *r.HostSoftwareInstallsExecutionID
|
||
}
|
||
}
|
||
|
||
require.NotEmpty(t, installUUID)
|
||
|
||
// Need to get the software title to get the package name
|
||
var getSoftwareTitleResp getSoftwareTitleResponse
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", *statusResp.Results.Software[0].SoftwareTitleID), nil, http.StatusOK, &getSoftwareTitleResp, "team_id", fmt.Sprintf("%d", *enrolledHost.TeamID))
|
||
require.NotNil(t, getSoftwareTitleResp.SoftwareTitle)
|
||
require.NotNil(t, getSoftwareTitleResp.SoftwareTitle.SoftwarePackage)
|
||
|
||
debugPrintActivities := func(activities []*fleet.UpcomingActivity) []string {
|
||
var res []string
|
||
for _, activity := range activities {
|
||
res = append(res, fmt.Sprintf("%+v", activity))
|
||
}
|
||
return res
|
||
}
|
||
|
||
// Check upcoming activities: we should only have the software upcoming because we don't run the
|
||
// script until after the software is done
|
||
var hostActivitiesResp listHostUpcomingActivitiesResponse
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", enrolledHost.ID),
|
||
nil, http.StatusOK, &hostActivitiesResp)
|
||
|
||
expectedActivityDetail := fmt.Sprintf(`
|
||
{
|
||
"status": "pending_install",
|
||
"host_id": %d,
|
||
"policy_id": null,
|
||
"policy_name": null,
|
||
"install_uuid": "%s",
|
||
"self_service": false,
|
||
"software_title": "%s",
|
||
"software_package": "%s",
|
||
"source": "apps",
|
||
"host_display_name": "%s"
|
||
}
|
||
`, enrolledHost.ID, installUUID, getSoftwareTitleResp.SoftwareTitle.Name, getSoftwareTitleResp.SoftwareTitle.SoftwarePackage.Name, enrolledHost.DisplayName())
|
||
require.Len(t, hostActivitiesResp.Activities, 1, "got activities: %v", debugPrintActivities(hostActivitiesResp.Activities))
|
||
require.NotNil(t, hostActivitiesResp.Activities[0].Details)
|
||
require.JSONEq(t, expectedActivityDetail, string(*hostActivitiesResp.Activities[0].Details))
|
||
|
||
// no MDM command got enqueued due to the /status call (device not released yet)
|
||
cmd, err = mdmDevice.Idle()
|
||
require.NoError(t, err)
|
||
require.Nil(t, cmd)
|
||
|
||
statusResp = fleet.GetOrbitSetupExperienceStatusResponse{}
|
||
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *enrolledHost.OrbitNodeKey)), http.StatusOK, &statusResp)
|
||
// Software is now running, script is still pending
|
||
require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusRunning, statusResp.Results.Software[0].Status)
|
||
require.NotNil(t, statusResp.Results.Software[0].SoftwareTitleID)
|
||
require.NotZero(t, *statusResp.Results.Software[0].SoftwareTitleID)
|
||
|
||
require.NotNil(t, statusResp.Results.Script)
|
||
require.Equal(t, "script.sh", statusResp.Results.Script.Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Script.Status)
|
||
|
||
// record a result for software installation
|
||
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 still shows script as pending
|
||
statusResp = fleet.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) // no bootstrap package involved
|
||
require.Nil(t, statusResp.Results.AccountConfiguration) // no SSO involved
|
||
require.Len(t, statusResp.Results.ConfigurationProfiles, 3) // fleetd config, root CA, custom profile
|
||
require.NotNil(t, statusResp.Results.Script)
|
||
require.Equal(t, "script.sh", statusResp.Results.Script.Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusRunning, statusResp.Results.Script.Status)
|
||
require.Len(t, statusResp.Results.Software, 1)
|
||
require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusSuccess, statusResp.Results.Software[0].Status)
|
||
|
||
// no MDM command got enqueued due to the /status call (device not released yet)
|
||
cmd, err = mdmDevice.Idle()
|
||
require.NoError(t, err)
|
||
require.Nil(t, cmd)
|
||
|
||
// Software is installed, now we should run the script
|
||
statusResp = fleet.GetOrbitSetupExperienceStatusResponse{}
|
||
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *enrolledHost.OrbitNodeKey)), http.StatusOK, &statusResp)
|
||
require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusSuccess, statusResp.Results.Software[0].Status)
|
||
require.NotNil(t, statusResp.Results.Software[0].SoftwareTitleID)
|
||
require.NotZero(t, *statusResp.Results.Software[0].SoftwareTitleID)
|
||
require.NotNil(t, statusResp.Results.Script)
|
||
require.Equal(t, "script.sh", statusResp.Results.Script.Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusRunning, statusResp.Results.Script.Status)
|
||
|
||
// Get script exec ID
|
||
results, err = s.ds.ListSetupExperienceResultsByHostUUID(ctx, enrolledHost.UUID, *enrolledHost.TeamID)
|
||
require.NoError(t, err)
|
||
require.Len(t, results, 2)
|
||
var execID string
|
||
for _, r := range results {
|
||
if r.ScriptExecutionID != nil {
|
||
execID = *r.ScriptExecutionID
|
||
}
|
||
}
|
||
|
||
// Validate past activity for software install
|
||
// For some reason the display name that's included in the `enrolledHost` is _slightly_
|
||
// different than the expected value in the activities. Pulling the host directly gets the
|
||
// correct display name.
|
||
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": "%s",
|
||
"software_package": "%s",
|
||
"self_service": false,
|
||
"install_uuid": "%s",
|
||
"status": "installed",
|
||
"source": "apps",
|
||
"policy_id": null,
|
||
"policy_name": null
|
||
}
|
||
`, enrolledHost.ID, getHostResp.Host.DisplayName, statusResp.Results.Software[0].Name, getSoftwareTitleResp.SoftwareTitle.SoftwarePackage.Name, installUUID)
|
||
|
||
s.lastActivityMatchesExtended(fleet.ActivityTypeInstalledSoftware{}.ActivityName(), expectedActivityDetail, 0, ptr.Bool(true))
|
||
|
||
// Validate upcoming activity for the script
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", enrolledHost.ID),
|
||
nil, http.StatusOK, &hostActivitiesResp)
|
||
|
||
expectedActivityDetail = fmt.Sprintf(`
|
||
{
|
||
"async": true,
|
||
"host_id": %d,
|
||
"policy_id": null,
|
||
"policy_name": null,
|
||
"script_name": "%s",
|
||
"host_display_name": "%s",
|
||
"script_execution_id": "%s",
|
||
"batch_execution_id": null
|
||
}
|
||
`, enrolledHost.ID, statusResp.Results.Script.Name, enrolledHost.DisplayName(), execID)
|
||
require.Len(t, hostActivitiesResp.Activities, 1, "got activities: %v", debugPrintActivities(hostActivitiesResp.Activities))
|
||
require.NotNil(t, hostActivitiesResp.Activities[0].Details)
|
||
require.JSONEq(t, expectedActivityDetail, string(*hostActivitiesResp.Activities[0].Details))
|
||
|
||
// record a result for script execution
|
||
var scriptResp fleet.OrbitPostScriptResultResponse
|
||
s.DoJSON("POST", "/api/fleet/orbit/scripts/result",
|
||
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "ok"}`, *enrolledHost.OrbitNodeKey, execID)),
|
||
http.StatusOK, &scriptResp)
|
||
|
||
// Get status again, now the script should be complete. This should also trigger the automatic
|
||
// release of the device, as all setup experience steps are now complete.
|
||
statusResp = fleet.GetOrbitSetupExperienceStatusResponse{}
|
||
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *enrolledHost.OrbitNodeKey)), http.StatusOK, &statusResp)
|
||
require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusSuccess, statusResp.Results.Software[0].Status)
|
||
require.NotNil(t, statusResp.Results.Software[0].SoftwareTitleID)
|
||
require.NotZero(t, *statusResp.Results.Software[0].SoftwareTitleID)
|
||
|
||
require.NotNil(t, statusResp.Results.Script)
|
||
require.Equal(t, "script.sh", statusResp.Results.Script.Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusSuccess, statusResp.Results.Script.Status)
|
||
|
||
// check that the host received the device configured command automatically
|
||
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)
|
||
var deviceConfiguredCount int
|
||
for _, cmd := range cmds {
|
||
switch cmd.Command.RequestType {
|
||
case "DeviceConfigured":
|
||
deviceConfiguredCount++
|
||
default:
|
||
otherCount++
|
||
}
|
||
}
|
||
require.Equal(t, 1, deviceConfiguredCount)
|
||
require.Equal(t, 0, otherCount)
|
||
|
||
// Validate activity for script run
|
||
expectedActivityDetail = fmt.Sprintf(`
|
||
{
|
||
"async": true,
|
||
"host_id": %d,
|
||
"policy_id": null,
|
||
"policy_name": null,
|
||
"script_name": "%s",
|
||
"host_display_name": "%s",
|
||
"script_execution_id": "%s",
|
||
"batch_execution_id": null
|
||
}
|
||
`, enrolledHost.ID, statusResp.Results.Script.Name, getHostResp.Host.DisplayName, execID)
|
||
|
||
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())
|
||
|
||
// Ensure fleet profiles
|
||
s.awaitTriggerProfileSchedule(t)
|
||
s.awaitRunAppleMDMWorkerSchedule()
|
||
s.runWorker()
|
||
|
||
// Drain the initial MDM commands (InstallProfile × 3 + InstallEnterpriseApplication × 1).
|
||
var cmds []*micromdm.CommandPayload
|
||
cmd, err := mdmDevice.Idle()
|
||
require.NoError(t, err)
|
||
for cmd != nil {
|
||
if cmd.Command.RequestType == "DeclarativeManagement" {
|
||
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
|
||
require.NoError(t, err)
|
||
continue
|
||
}
|
||
|
||
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 fleet.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, tm.ID)
|
||
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 = fleet.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 = fleet.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()
|
||
|
||
s.setSkipWorkerJobs(t)
|
||
|
||
teamDevice, enrolledHost, _ := s.createTeamDeviceForSetupExperienceWithProfileSoftwareAndScript()
|
||
|
||
// enroll the host
|
||
depURLToken := loadEnrollmentProfileDEPToken(t, s.ds)
|
||
mdmDevice := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, depURLToken)
|
||
mdmDevice.SerialNumber = teamDevice.SerialNumber
|
||
err := mdmDevice.Enroll()
|
||
require.NoError(t, err)
|
||
|
||
// ensure fleet profiles
|
||
s.awaitTriggerProfileSchedule(t)
|
||
// run the worker to process the DEP enroll request
|
||
s.awaitRunAppleMDMWorkerSchedule()
|
||
s.runWorker()
|
||
|
||
var cmds []*micromdm.CommandPayload
|
||
cmd, err := mdmDevice.Idle()
|
||
require.NoError(t, err)
|
||
for cmd != nil {
|
||
if cmd.Command.RequestType == "DeclarativeManagement" {
|
||
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
|
||
require.NoError(t, err)
|
||
continue
|
||
}
|
||
|
||
var fullCmd micromdm.CommandPayload
|
||
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
|
||
|
||
// Can be useful for debugging
|
||
// switch cmd.Command.RequestType {
|
||
// case "InstallProfile":
|
||
// fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType, string(fullCmd.Command.InstallProfile.Payload))
|
||
// case "InstallEnterpriseApplication":
|
||
// if fullCmd.Command.InstallEnterpriseApplication.ManifestURL != nil {
|
||
// fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType, *fullCmd.Command.InstallEnterpriseApplication.ManifestURL)
|
||
// } else {
|
||
// fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType)
|
||
// }
|
||
// default:
|
||
// fmt.Println(">>>> device received command: ", cmd.Command.RequestType)
|
||
// }
|
||
|
||
cmds = append(cmds, &fullCmd)
|
||
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
|
||
require.NoError(t, err)
|
||
}
|
||
|
||
// expected commands: install fleetd (install enterprise), install profiles
|
||
// (custom one, fleetd configuration, fleet CA root)
|
||
require.Len(t, cmds, 4)
|
||
var installProfileCount, installEnterpriseCount, otherCount int
|
||
var profileCustomSeen, profileFleetdSeen, profileFleetCASeen, profileFileVaultSeen bool
|
||
for _, cmd := range cmds {
|
||
switch cmd.Command.RequestType {
|
||
case "InstallProfile":
|
||
installProfileCount++
|
||
switch {
|
||
case strings.Contains(string(cmd.Command.InstallProfile.Payload), "<string>I1</string>"):
|
||
profileCustomSeen = true
|
||
case strings.Contains(string(cmd.Command.InstallProfile.Payload), fmt.Sprintf("<string>%s</string>", mobileconfig.FleetdConfigPayloadIdentifier)):
|
||
profileFleetdSeen = true
|
||
case strings.Contains(string(cmd.Command.InstallProfile.Payload), fmt.Sprintf("<string>%s</string>", mobileconfig.FleetCARootConfigPayloadIdentifier)):
|
||
profileFleetCASeen = true
|
||
case strings.Contains(string(cmd.Command.InstallProfile.Payload), fmt.Sprintf("<string>%s</string", mobileconfig.FleetFileVaultPayloadIdentifier)) &&
|
||
strings.Contains(string(cmd.Command.InstallProfile.Payload), "ForceEnableInSetupAssistant"):
|
||
profileFileVaultSeen = true
|
||
}
|
||
|
||
case "InstallEnterpriseApplication":
|
||
installEnterpriseCount++
|
||
default:
|
||
otherCount++
|
||
}
|
||
}
|
||
require.Equal(t, 3, installProfileCount)
|
||
require.Equal(t, 1, installEnterpriseCount)
|
||
require.Equal(t, 0, otherCount)
|
||
require.True(t, profileCustomSeen)
|
||
require.True(t, profileFleetdSeen)
|
||
require.True(t, profileFleetCASeen)
|
||
require.False(t, profileFileVaultSeen)
|
||
|
||
// simulate fleetd being installed and the host being orbit-enrolled now
|
||
enrolledHost.OsqueryHostID = ptr.String(mdmDevice.UUID)
|
||
orbitKey := setOrbitEnrollment(t, enrolledHost, s.ds)
|
||
enrolledHost.OrbitNodeKey = &orbitKey
|
||
|
||
// there shouldn't be a worker Release Device pending job (we don't release that way anymore)
|
||
pending, err := s.ds.GetQueuedJobs(ctx, 1, time.Now().UTC().Add(time.Minute))
|
||
require.NoError(t, err)
|
||
require.Len(t, pending, 0)
|
||
|
||
// call the /status endpoint, the software and script should be pending
|
||
var statusResp fleet.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) // no bootstrap package involved
|
||
require.Nil(t, statusResp.Results.AccountConfiguration) // no SSO involved
|
||
require.Len(t, statusResp.Results.ConfigurationProfiles, 3) // fleetd config, root CA, custom profile
|
||
var profNames []string
|
||
var profStatuses []fleet.MDMDeliveryStatus
|
||
for _, prof := range statusResp.Results.ConfigurationProfiles {
|
||
profNames = append(profNames, prof.Name)
|
||
profStatuses = append(profStatuses, prof.Status)
|
||
}
|
||
require.ElementsMatch(t, []string{"N1", "Fleetd configuration", "Fleet root certificate authority (CA)"}, profNames)
|
||
require.ElementsMatch(t, []fleet.MDMDeliveryStatus{fleet.MDMDeliveryVerifying, fleet.MDMDeliveryVerifying, fleet.MDMDeliveryVerifying}, profStatuses)
|
||
|
||
// the software and script are still pending
|
||
require.NotNil(t, statusResp.Results.Script)
|
||
require.Equal(t, "script.sh", statusResp.Results.Script.Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Script.Status)
|
||
require.Len(t, statusResp.Results.Software, 1)
|
||
require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Software[0].Status)
|
||
require.NotNil(t, statusResp.Results.Software[0].SoftwareTitleID)
|
||
require.NotZero(t, *statusResp.Results.Software[0].SoftwareTitleID)
|
||
|
||
// no MDM command got enqueued due to the /status call (device not released yet)
|
||
cmd, err = mdmDevice.Idle()
|
||
require.NoError(t, err)
|
||
require.Nil(t, cmd)
|
||
|
||
// call the /status endpoint again but this time force the release
|
||
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "force_release": true}`, *enrolledHost.OrbitNodeKey)), http.StatusOK, &statusResp)
|
||
|
||
// the software and script have not completed yet
|
||
require.NotNil(t, statusResp.Results.Script)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Script.Status)
|
||
require.Len(t, statusResp.Results.Software, 1)
|
||
require.Equal(t, fleet.SetupExperienceStatusRunning, statusResp.Results.Software[0].Status)
|
||
|
||
// check that the host received the device configured command even if
|
||
// software and script are still pending
|
||
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)
|
||
var deviceConfiguredCount int
|
||
for _, cmd := range cmds {
|
||
switch cmd.Command.RequestType {
|
||
case "DeviceConfigured":
|
||
deviceConfiguredCount++
|
||
default:
|
||
otherCount++
|
||
}
|
||
}
|
||
require.Equal(t, 1, deviceConfiguredCount)
|
||
require.Equal(t, 0, otherCount)
|
||
}
|
||
|
||
func (s *integrationMDMTestSuite) TestSetupExperienceVPPInstallError() {
|
||
t := s.T()
|
||
ctx := context.Background()
|
||
|
||
s.setSkipWorkerJobs(t)
|
||
|
||
teamDevice, enrolledHost, team := s.createTeamDeviceForSetupExperienceWithProfileSoftwareAndScript()
|
||
|
||
orgName := "Fleet Device Management Inc."
|
||
token := "mycooltoken"
|
||
expTime := time.Now().Add(200 * time.Hour).UTC().Round(time.Second)
|
||
expDate := expTime.Format(fleet.VPPTimeFormat)
|
||
tokenJSON := fmt.Sprintf(`{"expDate":"%s","token":"%s","orgName":"%s"}`, expDate, token, orgName)
|
||
dev_mode.SetOverride("FLEET_DEV_VPP_URL", s.appleVPPConfigSrv.URL, t)
|
||
var validToken uploadVPPTokenResponse
|
||
s.uploadDataViaForm("/api/latest/fleet/vpp_tokens", "token", "token.vpptoken", []byte(base64.StdEncoding.EncodeToString([]byte(tokenJSON))), http.StatusAccepted, "", &validToken)
|
||
|
||
var getVPPTokenResp getVPPTokensResponse
|
||
s.DoJSON("GET", "/api/latest/fleet/vpp_tokens", &getVPPTokensRequest{}, http.StatusOK, &getVPPTokenResp)
|
||
|
||
// Add an app with 0 licenses available
|
||
s.appleVPPConfigSrvConfig.Assets = append(s.appleVPPConfigSrvConfig.Assets, vpp.Asset{
|
||
AdamID: "5",
|
||
PricingParam: "STDQ",
|
||
AvailableCount: 0,
|
||
})
|
||
|
||
t.Cleanup(func() {
|
||
s.appleVPPConfigSrvConfig.Assets = defaultVPPAssetList
|
||
})
|
||
|
||
// Associate team to the VPP token.
|
||
var resPatchVPP patchVPPTokensTeamsResponse
|
||
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", getVPPTokenResp.Tokens[0].ID), patchVPPTokensTeamsRequest{TeamIDs: []uint{team.ID}}, http.StatusOK, &resPatchVPP)
|
||
|
||
// Add the app with 0 licenses available
|
||
s.Do("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{TeamID: &team.ID, AppStoreID: "5", SelfService: true}, http.StatusOK)
|
||
|
||
// Add the VPP app to setup experience
|
||
vppTitleID := getSoftwareTitleID(t, s.ds, "App 5", "apps")
|
||
installerTitleID := getSoftwareTitleID(t, s.ds, "DummyApp", "apps")
|
||
var swInstallResp putSetupExperienceSoftwareResponse
|
||
s.DoJSON("PUT", "/api/v1/fleet/setup_experience/software", putSetupExperienceSoftwareRequest{TeamID: team.ID, TitleIDs: []uint{vppTitleID, installerTitleID}}, http.StatusOK, &swInstallResp)
|
||
|
||
// enroll the host
|
||
depURLToken := loadEnrollmentProfileDEPToken(t, s.ds)
|
||
mdmDevice := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, depURLToken)
|
||
mdmDevice.SerialNumber = teamDevice.SerialNumber
|
||
err := mdmDevice.Enroll()
|
||
require.NoError(t, err)
|
||
|
||
// ensure fleet profiles
|
||
s.awaitTriggerProfileSchedule(t)
|
||
// run the worker to process the DEP enroll request
|
||
s.awaitRunAppleMDMWorkerSchedule()
|
||
s.runWorker()
|
||
|
||
var cmds []*micromdm.CommandPayload
|
||
cmd, err := mdmDevice.Idle()
|
||
require.NoError(t, err)
|
||
for cmd != nil {
|
||
if cmd.Command.RequestType == "DeclarativeManagement" {
|
||
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
|
||
require.NoError(t, err)
|
||
continue
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
// expected commands: install fleetd (install enterprise), install profiles
|
||
// (custom one, fleetd configuration, fleet CA root)
|
||
require.Len(t, cmds, 4)
|
||
var installProfileCount, installEnterpriseCount, otherCount int
|
||
var profileCustomSeen, profileFleetdSeen, profileFleetCASeen, profileFileVaultSeen bool
|
||
for _, cmd := range cmds {
|
||
switch cmd.Command.RequestType {
|
||
case "InstallProfile":
|
||
installProfileCount++
|
||
switch {
|
||
case strings.Contains(string(cmd.Command.InstallProfile.Payload), "<string>I1</string>"):
|
||
profileCustomSeen = true
|
||
case strings.Contains(string(cmd.Command.InstallProfile.Payload), fmt.Sprintf("<string>%s</string>", mobileconfig.FleetdConfigPayloadIdentifier)):
|
||
profileFleetdSeen = true
|
||
case strings.Contains(string(cmd.Command.InstallProfile.Payload), fmt.Sprintf("<string>%s</string>", mobileconfig.FleetCARootConfigPayloadIdentifier)):
|
||
profileFleetCASeen = true
|
||
case strings.Contains(string(cmd.Command.InstallProfile.Payload), fmt.Sprintf("<string>%s</string", mobileconfig.FleetFileVaultPayloadIdentifier)) &&
|
||
strings.Contains(string(cmd.Command.InstallProfile.Payload), "ForceEnableInSetupAssistant"):
|
||
profileFileVaultSeen = true
|
||
}
|
||
|
||
case "InstallEnterpriseApplication":
|
||
installEnterpriseCount++
|
||
default:
|
||
otherCount++
|
||
}
|
||
}
|
||
require.Equal(t, 3, installProfileCount)
|
||
require.Equal(t, 1, installEnterpriseCount)
|
||
require.Equal(t, 0, otherCount)
|
||
require.True(t, profileCustomSeen)
|
||
require.True(t, profileFleetdSeen)
|
||
require.True(t, profileFleetCASeen)
|
||
require.False(t, profileFileVaultSeen)
|
||
|
||
// simulate fleetd being installed and the host being orbit-enrolled now
|
||
enrolledHost.OsqueryHostID = ptr.String(mdmDevice.UUID)
|
||
enrolledHost.UUID = mdmDevice.UUID
|
||
orbitKey := setOrbitEnrollment(t, enrolledHost, s.ds)
|
||
enrolledHost.OrbitNodeKey = &orbitKey
|
||
|
||
// there shouldn't be a worker Release Device pending job (we don't release that way anymore)
|
||
pending, err := s.ds.GetQueuedJobs(ctx, 1, time.Now().UTC().Add(time.Minute))
|
||
require.NoError(t, err)
|
||
require.Len(t, pending, 0)
|
||
|
||
// call the /status endpoint, the software and script should be pending
|
||
var statusResp fleet.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) // no bootstrap package involved
|
||
require.Nil(t, statusResp.Results.AccountConfiguration) // no SSO involved
|
||
require.Len(t, statusResp.Results.ConfigurationProfiles, 3) // fleetd config, root CA, custom profile
|
||
var profNames []string
|
||
var profStatuses []fleet.MDMDeliveryStatus
|
||
for _, prof := range statusResp.Results.ConfigurationProfiles {
|
||
profNames = append(profNames, prof.Name)
|
||
profStatuses = append(profStatuses, prof.Status)
|
||
}
|
||
require.ElementsMatch(t, []string{"N1", "Fleetd configuration", "Fleet root certificate authority (CA)"}, profNames)
|
||
require.ElementsMatch(t, []fleet.MDMDeliveryStatus{fleet.MDMDeliveryVerifying, fleet.MDMDeliveryVerifying, fleet.MDMDeliveryVerifying}, profStatuses)
|
||
require.NotNil(t, statusResp.Results.Script)
|
||
require.Equal(t, "script.sh", statusResp.Results.Script.Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Script.Status)
|
||
require.Len(t, statusResp.Results.Software, 2)
|
||
require.Equal(t, "App 5", statusResp.Results.Software[0].Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Software[0].Status)
|
||
require.NotNil(t, statusResp.Results.Software[0].SoftwareTitleID)
|
||
require.NotZero(t, *statusResp.Results.Software[0].SoftwareTitleID)
|
||
require.Equal(t, "DummyApp", statusResp.Results.Software[1].Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Software[1].Status)
|
||
|
||
// Get status: the VPP app install should have run and failed.
|
||
statusResp = fleet.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) // no bootstrap package involved
|
||
require.Nil(t, statusResp.Results.AccountConfiguration) // no SSO involved
|
||
require.Len(t, statusResp.Results.ConfigurationProfiles, 3) // fleetd config, root CA, custom profile
|
||
require.Len(t, statusResp.Results.Software, 2)
|
||
// App 5 has no licenses available, so we should get a status failed here...
|
||
require.Equal(t, "App 5", statusResp.Results.Software[0].Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusFailure, statusResp.Results.Software[0].Status)
|
||
// ...but setup experience should still continue with the next app in the list
|
||
require.Equal(t, "DummyApp", statusResp.Results.Software[1].Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Software[1].Status)
|
||
// Script goes last
|
||
require.NotNil(t, statusResp.Results.Script)
|
||
require.Equal(t, "script.sh", statusResp.Results.Script.Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Script.Status)
|
||
|
||
// The status for DummyApp should be "running" now, since it's started installing
|
||
// but we haven't sent back an installation status from orbit yet
|
||
statusResp = fleet.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, 2)
|
||
require.Equal(t, "DummyApp", statusResp.Results.Software[1].Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusRunning, statusResp.Results.Software[1].Status)
|
||
|
||
// The /setup_experience/status endpoint doesn't return the various IDs for executions, so pull
|
||
// exec ID for "DummyApp" out manually
|
||
results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, enrolledHost.UUID, team.ID)
|
||
require.NoError(t, err)
|
||
require.Len(t, results, 3)
|
||
var installUUID string
|
||
for _, r := range results {
|
||
if r.HostSoftwareInstallsExecutionID != nil &&
|
||
r.SoftwareInstallerID != nil &&
|
||
r.Name == statusResp.Results.Software[1].Name {
|
||
installUUID = *r.HostSoftwareInstallsExecutionID
|
||
break
|
||
}
|
||
}
|
||
|
||
require.NotEmpty(t, installUUID)
|
||
|
||
// Need to get the software title to get the package name
|
||
var getSoftwareTitleResp getSoftwareTitleResponse
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", *statusResp.Results.Software[1].SoftwareTitleID), nil, http.StatusOK, &getSoftwareTitleResp, "team_id", fmt.Sprintf("%d", *enrolledHost.TeamID))
|
||
require.NotNil(t, getSoftwareTitleResp.SoftwareTitle)
|
||
require.NotNil(t, getSoftwareTitleResp.SoftwareTitle.SoftwarePackage)
|
||
|
||
// record a result for software installation
|
||
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)
|
||
|
||
// Script is already running, poll again to confirm status
|
||
statusResp = fleet.GetOrbitSetupExperienceStatusResponse{}
|
||
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *enrolledHost.OrbitNodeKey)), http.StatusOK, &statusResp)
|
||
require.Equal(t, "App 5", statusResp.Results.Software[0].Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusFailure, statusResp.Results.Software[0].Status)
|
||
require.NotNil(t, statusResp.Results.Software[0].SoftwareTitleID)
|
||
require.NotZero(t, *statusResp.Results.Software[0].SoftwareTitleID)
|
||
require.NotNil(t, statusResp.Results.Script)
|
||
require.Equal(t, "script.sh", statusResp.Results.Script.Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusRunning, statusResp.Results.Script.Status)
|
||
require.Equal(t, "DummyApp", statusResp.Results.Software[1].Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusSuccess, statusResp.Results.Software[1].Status)
|
||
|
||
// Get script exec ID
|
||
results, err = s.ds.ListSetupExperienceResultsByHostUUID(ctx, enrolledHost.UUID, team.ID)
|
||
require.NoError(t, err)
|
||
require.Len(t, results, 3)
|
||
var execID string
|
||
for _, r := range results {
|
||
if r.ScriptExecutionID != nil {
|
||
execID = *r.ScriptExecutionID
|
||
}
|
||
}
|
||
|
||
// record a result for script execution
|
||
var scriptResp fleet.OrbitPostScriptResultResponse
|
||
s.DoJSON("POST", "/api/fleet/orbit/scripts/result",
|
||
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "ok"}`, *enrolledHost.OrbitNodeKey, execID)),
|
||
http.StatusOK, &scriptResp)
|
||
|
||
// Get status again, now the script should be complete. This should also trigger the automatic
|
||
// release of the device, as all setup experience steps are now complete.
|
||
statusResp = fleet.GetOrbitSetupExperienceStatusResponse{}
|
||
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *enrolledHost.OrbitNodeKey)), http.StatusOK, &statusResp)
|
||
require.Equal(t, "App 5", statusResp.Results.Software[0].Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusFailure, statusResp.Results.Software[0].Status)
|
||
require.NotNil(t, statusResp.Results.Software[0].SoftwareTitleID)
|
||
require.NotZero(t, *statusResp.Results.Software[0].SoftwareTitleID)
|
||
require.Equal(t, "DummyApp", statusResp.Results.Software[1].Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusSuccess, statusResp.Results.Software[1].Status)
|
||
|
||
require.NotNil(t, statusResp.Results.Script)
|
||
require.Equal(t, "script.sh", statusResp.Results.Script.Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusSuccess, statusResp.Results.Script.Status)
|
||
|
||
// check that the host received the device configured command automatically
|
||
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)
|
||
var deviceConfiguredCount int
|
||
for _, cmd := range cmds {
|
||
switch cmd.Command.RequestType {
|
||
case "DeviceConfigured":
|
||
deviceConfiguredCount++
|
||
default:
|
||
otherCount++
|
||
}
|
||
}
|
||
require.Equal(t, 1, deviceConfiguredCount)
|
||
require.Equal(t, 0, otherCount)
|
||
}
|
||
|
||
func (s *integrationMDMTestSuite) TestSetupExperienceFlowUpdateScript() {
|
||
t := s.T()
|
||
ctx := context.Background()
|
||
|
||
s.setSkipWorkerJobs(t)
|
||
|
||
device, host, tm := s.createTeamDeviceForSetupExperienceWithProfileSoftwareAndScript()
|
||
|
||
// enroll the host
|
||
depURLToken := loadEnrollmentProfileDEPToken(t, s.ds)
|
||
mdmDevice := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, depURLToken)
|
||
mdmDevice.SerialNumber = device.SerialNumber
|
||
err := mdmDevice.Enroll()
|
||
require.NoError(t, err)
|
||
|
||
// run the worker to assign fleet profiles
|
||
s.awaitTriggerProfileSchedule(t)
|
||
|
||
// run the worker to process the DEP enroll request
|
||
s.awaitRunAppleMDMWorkerSchedule()
|
||
s.runWorker()
|
||
|
||
var cmds []*micromdm.CommandPayload
|
||
cmd, err := mdmDevice.Idle()
|
||
require.NoError(t, err)
|
||
for cmd != nil {
|
||
if cmd.Command.RequestType == "DeclarativeManagement" {
|
||
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
|
||
require.NoError(t, err)
|
||
continue
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
// expected commands: install fleetd (install enterprise), install profiles
|
||
// (custom one, fleetd configuration, fleet CA root)
|
||
require.Len(t, cmds, 4)
|
||
|
||
// simulate fleetd being installed and the host being orbit-enrolled now
|
||
host.OsqueryHostID = ptr.String(mdmDevice.UUID)
|
||
host.UUID = mdmDevice.UUID
|
||
orbitKey := setOrbitEnrollment(t, host, s.ds)
|
||
host.OrbitNodeKey = &orbitKey
|
||
|
||
// call the /status endpoint, the software and script should be pending
|
||
var statusResp fleet.GetOrbitSetupExperienceStatusResponse
|
||
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *host.OrbitNodeKey)), http.StatusOK, &statusResp)
|
||
require.Nil(t, statusResp.Results.BootstrapPackage) // no bootstrap package involved
|
||
require.Nil(t, statusResp.Results.AccountConfiguration) // no SSO involved
|
||
require.Len(t, statusResp.Results.ConfigurationProfiles, 3) // fleetd config, root CA, custom profile
|
||
|
||
// the software and script are pending
|
||
require.NotNil(t, statusResp.Results.Script)
|
||
require.Equal(t, "script.sh", statusResp.Results.Script.Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Script.Status)
|
||
require.Len(t, statusResp.Results.Software, 1)
|
||
require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Software[0].Status)
|
||
require.NotNil(t, statusResp.Results.Software[0].SoftwareTitleID)
|
||
require.NotZero(t, *statusResp.Results.Software[0].SoftwareTitleID)
|
||
|
||
// The /setup_experience/status endpoint doesn't return the various IDs for executions, so pull
|
||
// it out manually (for now only the software install has its execution id)
|
||
results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, host.UUID, tm.ID)
|
||
require.NoError(t, err)
|
||
require.Len(t, results, 2)
|
||
|
||
var swExecID string
|
||
for _, r := range results {
|
||
if r.HostSoftwareInstallsExecutionID != nil {
|
||
swExecID = *r.HostSoftwareInstallsExecutionID
|
||
}
|
||
}
|
||
require.NotEmpty(t, swExecID)
|
||
|
||
// Check upcoming activities: we should only have the software upcoming because we don't run the
|
||
// script until after the software is done
|
||
var hostActivitiesResp listHostUpcomingActivitiesResponse
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", host.ID),
|
||
nil, http.StatusOK, &hostActivitiesResp)
|
||
require.Len(t, hostActivitiesResp.Activities, 1)
|
||
require.Equal(t, swExecID, hostActivitiesResp.Activities[0].UUID)
|
||
|
||
// no MDM command got enqueued due to the /status call (device not released yet)
|
||
cmd, err = mdmDevice.Idle()
|
||
require.NoError(t, err)
|
||
require.Nil(t, cmd)
|
||
|
||
// call the /status endpoint, the software is now running and script should be pending
|
||
statusResp = fleet.GetOrbitSetupExperienceStatusResponse{}
|
||
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *host.OrbitNodeKey)), http.StatusOK, &statusResp)
|
||
require.Nil(t, statusResp.Results.BootstrapPackage) // no bootstrap package involved
|
||
require.Nil(t, statusResp.Results.AccountConfiguration) // no SSO involved
|
||
require.Len(t, statusResp.Results.ConfigurationProfiles, 3) // fleetd config, root CA, custom profile
|
||
|
||
// the software is running and script is pending
|
||
require.NotNil(t, statusResp.Results.Script)
|
||
require.Equal(t, "script.sh", statusResp.Results.Script.Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Script.Status)
|
||
require.Len(t, statusResp.Results.Software, 1)
|
||
require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusRunning, statusResp.Results.Software[0].Status)
|
||
require.NotNil(t, statusResp.Results.Software[0].SoftwareTitleID)
|
||
require.NotZero(t, *statusResp.Results.Software[0].SoftwareTitleID)
|
||
|
||
// no MDM command got enqueued due to the /status call (device not released yet)
|
||
cmd, err = mdmDevice.Idle()
|
||
require.NoError(t, err)
|
||
require.Nil(t, cmd)
|
||
|
||
// update the script but with no actual changes, it does not get cancelled
|
||
body, headers := generateNewScriptMultipartRequest(t,
|
||
"script.sh", []byte(`echo "hello"`), s.token, map[string][]string{"team_id": {fmt.Sprintf("%d", tm.ID)}})
|
||
s.DoRawWithHeaders("POST", "/api/latest/fleet/setup_experience/script", body.Bytes(), http.StatusOK, headers)
|
||
|
||
// call the /status endpoint, the software is still running and script should still be pending
|
||
statusResp = fleet.GetOrbitSetupExperienceStatusResponse{}
|
||
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *host.OrbitNodeKey)), http.StatusOK, &statusResp)
|
||
require.Nil(t, statusResp.Results.BootstrapPackage) // no bootstrap package involved
|
||
require.Nil(t, statusResp.Results.AccountConfiguration) // no SSO involved
|
||
require.Len(t, statusResp.Results.ConfigurationProfiles, 3) // fleetd config, root CA, custom profile
|
||
|
||
// the software is still running and script is pending
|
||
require.NotNil(t, statusResp.Results.Script)
|
||
require.Equal(t, "script.sh", statusResp.Results.Script.Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Script.Status)
|
||
require.Len(t, statusResp.Results.Software, 1)
|
||
require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusRunning, statusResp.Results.Software[0].Status)
|
||
require.NotNil(t, statusResp.Results.Software[0].SoftwareTitleID)
|
||
require.NotZero(t, *statusResp.Results.Software[0].SoftwareTitleID)
|
||
|
||
// update the script with changes, see it get cancelled
|
||
body, headers = generateNewScriptMultipartRequest(t,
|
||
"script2.sh", []byte(`echo "foobar"`), s.token, map[string][]string{"team_id": {fmt.Sprintf("%d", tm.ID)}})
|
||
s.DoRawWithHeaders("POST", "/api/latest/fleet/setup_experience/script", body.Bytes(), http.StatusOK, headers)
|
||
|
||
// call the /status endpoint, software is running, script is removed as it got cancelled by the update
|
||
statusResp = fleet.GetOrbitSetupExperienceStatusResponse{}
|
||
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *host.OrbitNodeKey)), http.StatusOK, &statusResp)
|
||
require.Nil(t, statusResp.Results.BootstrapPackage) // no bootstrap package involved
|
||
require.Nil(t, statusResp.Results.AccountConfiguration) // no SSO involved
|
||
require.Len(t, statusResp.Results.ConfigurationProfiles, 3) // fleetd config, root CA, custom profile
|
||
|
||
require.Len(t, statusResp.Results.Software, 1)
|
||
require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusRunning, statusResp.Results.Software[0].Status)
|
||
require.NotNil(t, statusResp.Results.Software[0].SoftwareTitleID)
|
||
require.NotZero(t, *statusResp.Results.Software[0].SoftwareTitleID)
|
||
|
||
require.Nil(t, statusResp.Results.Script)
|
||
|
||
// The /setup_experience/status endpoint doesn't return the various IDs for executions, so pull
|
||
// them out manually
|
||
results, err = s.ds.ListSetupExperienceResultsByHostUUID(ctx, host.UUID, tm.ID)
|
||
require.NoError(t, err)
|
||
require.Len(t, results, 1)
|
||
var installUUIDs []string
|
||
for _, r := range results {
|
||
if r.HostSoftwareInstallsExecutionID != nil {
|
||
installUUIDs = append(installUUIDs, *r.HostSoftwareInstallsExecutionID)
|
||
}
|
||
}
|
||
require.Equal(t, len(installUUIDs), 1)
|
||
|
||
// record a result for software installation
|
||
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"
|
||
}`, *host.OrbitNodeKey, installUUIDs[0])), http.StatusNoContent)
|
||
|
||
// Check the setup experience status endpoint to advance the status
|
||
statusResp = fleet.GetOrbitSetupExperienceStatusResponse{}
|
||
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *host.OrbitNodeKey)), http.StatusOK, &statusResp)
|
||
|
||
// check that the host received the device configured command automatically
|
||
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)
|
||
}
|
||
|
||
func (s *integrationMDMTestSuite) TestSetupExperienceFlowCancelScript() {
|
||
t := s.T()
|
||
ctx := context.Background()
|
||
|
||
s.setSkipWorkerJobs(t)
|
||
|
||
device, host, _ := s.createTeamDeviceForSetupExperienceWithProfileSoftwareAndScript()
|
||
|
||
// enroll the host
|
||
depURLToken := loadEnrollmentProfileDEPToken(t, s.ds)
|
||
mdmDevice := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, depURLToken)
|
||
mdmDevice.SerialNumber = device.SerialNumber
|
||
err := mdmDevice.Enroll()
|
||
require.NoError(t, err)
|
||
|
||
// ensure fleet profiles
|
||
s.awaitTriggerProfileSchedule(t)
|
||
// run the worker to process the DEP enroll request
|
||
s.awaitRunAppleMDMWorkerSchedule()
|
||
|
||
var cmds []*micromdm.CommandPayload
|
||
cmd, err := mdmDevice.Idle()
|
||
require.NoError(t, err)
|
||
for cmd != nil {
|
||
if cmd.Command.RequestType == "DeclarativeManagement" {
|
||
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
|
||
require.NoError(t, err)
|
||
continue
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
// expected commands: install fleetd (install enterprise), install profiles
|
||
// (custom one, fleetd configuration, fleet CA root)
|
||
require.Len(t, cmds, 4)
|
||
|
||
// simulate fleetd being installed and the host being orbit-enrolled now
|
||
host.OsqueryHostID = ptr.String(mdmDevice.UUID)
|
||
host.UUID = mdmDevice.UUID
|
||
orbitKey := setOrbitEnrollment(t, host, s.ds)
|
||
host.OrbitNodeKey = &orbitKey
|
||
|
||
// call the /status endpoint, the software and script should be pending
|
||
var statusResp fleet.GetOrbitSetupExperienceStatusResponse
|
||
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *host.OrbitNodeKey)), http.StatusOK, &statusResp)
|
||
require.Nil(t, statusResp.Results.BootstrapPackage) // no bootstrap package involved
|
||
require.Nil(t, statusResp.Results.AccountConfiguration) // no SSO involved
|
||
require.Len(t, statusResp.Results.ConfigurationProfiles, 3) // fleetd config, root CA, custom profile
|
||
|
||
// the software and script are pending
|
||
require.NotNil(t, statusResp.Results.Script)
|
||
require.Equal(t, "script.sh", statusResp.Results.Script.Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Script.Status)
|
||
require.Len(t, statusResp.Results.Software, 1)
|
||
require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Software[0].Status)
|
||
require.NotNil(t, statusResp.Results.Software[0].SoftwareTitleID)
|
||
require.NotZero(t, *statusResp.Results.Software[0].SoftwareTitleID)
|
||
|
||
// The /setup_experience/status endpoint doesn't return the various IDs for executions, so pull
|
||
// it out manually (for now only the software install has its execution id)
|
||
results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, host.UUID, *host.TeamID)
|
||
require.NoError(t, err)
|
||
require.Len(t, results, 2)
|
||
|
||
var swExecID string
|
||
for _, r := range results {
|
||
if r.HostSoftwareInstallsExecutionID != nil {
|
||
swExecID = *r.HostSoftwareInstallsExecutionID
|
||
}
|
||
}
|
||
require.NotEmpty(t, swExecID)
|
||
|
||
// Check upcoming activities: we should only have the software upcoming because we don't run the
|
||
// script until after the software is done
|
||
var hostActivitiesResp listHostUpcomingActivitiesResponse
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", host.ID),
|
||
nil, http.StatusOK, &hostActivitiesResp)
|
||
require.Len(t, hostActivitiesResp.Activities, 1)
|
||
require.Equal(t, swExecID, hostActivitiesResp.Activities[0].UUID)
|
||
|
||
// no MDM command got enqueued due to the /status call (device not released yet)
|
||
cmd, err = mdmDevice.Idle()
|
||
require.NoError(t, err)
|
||
require.Nil(t, cmd)
|
||
|
||
// cancel the software install
|
||
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming/%s", host.ID, swExecID),
|
||
nil, http.StatusNoContent)
|
||
|
||
// call the /status endpoint, the software is now failed and script should be pending
|
||
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *host.OrbitNodeKey)), http.StatusOK, &statusResp)
|
||
require.Nil(t, statusResp.Results.BootstrapPackage) // no bootstrap package involved
|
||
require.Nil(t, statusResp.Results.AccountConfiguration) // no SSO involved
|
||
require.Len(t, statusResp.Results.ConfigurationProfiles, 3) // fleetd config, root CA, custom profile
|
||
|
||
// the software is failed and script is pending
|
||
require.NotNil(t, statusResp.Results.Script)
|
||
require.Equal(t, "script.sh", statusResp.Results.Script.Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Script.Status)
|
||
require.Len(t, statusResp.Results.Software, 1)
|
||
require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusFailure, statusResp.Results.Software[0].Status)
|
||
require.NotNil(t, statusResp.Results.Software[0].SoftwareTitleID)
|
||
require.NotZero(t, *statusResp.Results.Software[0].SoftwareTitleID)
|
||
|
||
// The /setup_experience/status endpoint doesn't return the various IDs for executions, so pull
|
||
// it out manually (this time get the script exec ID)
|
||
results, err = s.ds.ListSetupExperienceResultsByHostUUID(ctx, host.UUID, *host.TeamID)
|
||
require.NoError(t, err)
|
||
require.Len(t, results, 2)
|
||
|
||
var scrExecID string
|
||
for _, r := range results {
|
||
if r.ScriptExecutionID != nil {
|
||
scrExecID = *r.ScriptExecutionID
|
||
}
|
||
}
|
||
require.NotEmpty(t, scrExecID)
|
||
|
||
// script is now in the upcoming activities
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", host.ID),
|
||
nil, http.StatusOK, &hostActivitiesResp)
|
||
require.Len(t, hostActivitiesResp.Activities, 1)
|
||
require.Equal(t, scrExecID, hostActivitiesResp.Activities[0].UUID)
|
||
|
||
// no MDM command got enqueued due to the /status call (device not released yet)
|
||
cmd, err = mdmDevice.Idle()
|
||
require.NoError(t, err)
|
||
require.Nil(t, cmd)
|
||
|
||
// cancel the script
|
||
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming/%s", host.ID, scrExecID),
|
||
nil, http.StatusNoContent)
|
||
|
||
// call the /status endpoint, both the software and script are now failed
|
||
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *host.OrbitNodeKey)), http.StatusOK, &statusResp)
|
||
require.Nil(t, statusResp.Results.BootstrapPackage) // no bootstrap package involved
|
||
require.Nil(t, statusResp.Results.AccountConfiguration) // no SSO involved
|
||
require.Len(t, statusResp.Results.ConfigurationProfiles, 3) // fleetd config, root CA, custom profile
|
||
|
||
require.NotNil(t, statusResp.Results.Script)
|
||
require.Equal(t, "script.sh", statusResp.Results.Script.Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusFailure, statusResp.Results.Script.Status)
|
||
require.Len(t, statusResp.Results.Software, 1)
|
||
require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusFailure, statusResp.Results.Software[0].Status)
|
||
require.NotNil(t, statusResp.Results.Software[0].SoftwareTitleID)
|
||
require.NotZero(t, *statusResp.Results.Software[0].SoftwareTitleID)
|
||
|
||
// check that the host received the device configured command automatically
|
||
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)
|
||
}
|
||
|
||
func (s *integrationMDMTestSuite) TestSetupExperienceWithLotsOfVPPApps() {
|
||
t := s.T()
|
||
ctx := context.Background()
|
||
s.setSkipWorkerJobs(t)
|
||
|
||
s.registerResetVPPProxyData(t)
|
||
|
||
// Set up some additional VPP apps on the mock the Fleet proxy to Apple servers
|
||
s.appleVPPProxySrvData["6"] = `{"id": "6", "attributes": {"name": "App 6", "platformAttributes": {"osx": {"bundleId": "f-6", "artwork": {"url": "https://example.com/images/6/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "6.0.0"}}}, "deviceFamilies": ["mac"]}}`
|
||
s.appleVPPProxySrvData["7"] = `{"id": "7", "attributes": {"name": "App 7", "platformAttributes": {"osx": {"bundleId": "g-7", "artwork": {"url": "https://example.com/images/7/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "7.0.0"}}}, "deviceFamilies": ["mac"]}}`
|
||
s.appleVPPProxySrvData["8"] = `{"id": "8", "attributes": {"name": "App 8", "platformAttributes": {"osx": {"bundleId": "h-8", "artwork": {"url": "https://example.com/images/8/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "8.0.0"}}}, "deviceFamilies": ["mac"]}}`
|
||
s.appleVPPProxySrvData["9"] = `{"id": "9", "attributes": {"name": "App 9", "platformAttributes": {"osx": {"bundleId": "i-9", "artwork": {"url": "https://example.com/images/9/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "9.0.0"}}}, "deviceFamilies": ["mac"]}}`
|
||
s.appleVPPProxySrvData["10"] = `{"id": "10", "attributes": {"name": "App 10", "platformAttributes": {"osx": {"bundleId": "j-10", "artwork": {"url": "https://example.com/images/10/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "10.0.0"}}}, "deviceFamilies": ["mac"]}}`
|
||
|
||
s.appleVPPConfigSrvConfig.Assets = append(s.appleVPPConfigSrvConfig.Assets, []vpp.Asset{
|
||
{
|
||
AdamID: "6",
|
||
PricingParam: "STDQ",
|
||
AvailableCount: 1,
|
||
},
|
||
{
|
||
AdamID: "7",
|
||
PricingParam: "STDQ",
|
||
AvailableCount: 1,
|
||
},
|
||
{
|
||
AdamID: "8",
|
||
PricingParam: "STDQ",
|
||
AvailableCount: 1,
|
||
},
|
||
{
|
||
AdamID: "9",
|
||
PricingParam: "STDQ",
|
||
AvailableCount: 1,
|
||
},
|
||
{
|
||
AdamID: "10",
|
||
PricingParam: "STDQ",
|
||
AvailableCount: 1,
|
||
},
|
||
}...)
|
||
|
||
t.Cleanup(func() {
|
||
s.appleVPPConfigSrvConfig.Assets = defaultVPPAssetList
|
||
})
|
||
|
||
getSoftwareTitleIDFromApp := func(app *fleet.VPPApp) uint {
|
||
var titleID uint
|
||
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
||
ctx := context.Background()
|
||
return sqlx.GetContext(ctx, q, &titleID, `SELECT title_id FROM vpp_apps WHERE adam_id = ? AND platform = ?`, app.AdamID, app.Platform)
|
||
})
|
||
|
||
return titleID
|
||
}
|
||
|
||
teamDevice, enrolledHost, _ := s.createTeamDeviceForSetupExperienceWithProfileSoftwareAndScript()
|
||
require.NotNil(t, enrolledHost.TeamID)
|
||
|
||
s.setVPPTokenForTeam(*enrolledHost.TeamID)
|
||
s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, teamDevice.SerialNumber)
|
||
|
||
// Add some VPP apps
|
||
macOSApp1 := &fleet.VPPApp{
|
||
VPPAppTeam: fleet.VPPAppTeam{
|
||
VPPAppID: fleet.VPPAppID{
|
||
AdamID: "1",
|
||
Platform: fleet.MacOSPlatform,
|
||
},
|
||
},
|
||
Name: "App 1",
|
||
BundleIdentifier: "a-1",
|
||
IconURL: "https://example.com/images/1",
|
||
LatestVersion: "1.0.0",
|
||
}
|
||
|
||
macOSApp2 := &fleet.VPPApp{
|
||
VPPAppTeam: fleet.VPPAppTeam{
|
||
VPPAppID: fleet.VPPAppID{
|
||
AdamID: "6",
|
||
Platform: fleet.MacOSPlatform,
|
||
},
|
||
},
|
||
Name: "App 6",
|
||
BundleIdentifier: "f-6",
|
||
IconURL: "https://example.com/images/6",
|
||
LatestVersion: "6.0.0",
|
||
}
|
||
macOSApp3 := &fleet.VPPApp{
|
||
VPPAppTeam: fleet.VPPAppTeam{
|
||
VPPAppID: fleet.VPPAppID{
|
||
AdamID: "7",
|
||
Platform: fleet.MacOSPlatform,
|
||
},
|
||
},
|
||
Name: "App 7",
|
||
BundleIdentifier: "g-7",
|
||
IconURL: "https://example.com/images/7",
|
||
LatestVersion: "7.0.0",
|
||
}
|
||
macOSApp4 := &fleet.VPPApp{
|
||
VPPAppTeam: fleet.VPPAppTeam{
|
||
VPPAppID: fleet.VPPAppID{
|
||
AdamID: "8",
|
||
Platform: fleet.MacOSPlatform,
|
||
},
|
||
},
|
||
Name: "App 8",
|
||
BundleIdentifier: "h-8",
|
||
IconURL: "https://example.com/images/8",
|
||
LatestVersion: "8.0.0",
|
||
}
|
||
macOSApp5 := &fleet.VPPApp{
|
||
VPPAppTeam: fleet.VPPAppTeam{
|
||
VPPAppID: fleet.VPPAppID{
|
||
AdamID: "9",
|
||
Platform: fleet.MacOSPlatform,
|
||
},
|
||
},
|
||
Name: "App 9",
|
||
BundleIdentifier: "i-9",
|
||
IconURL: "https://example.com/images/9",
|
||
LatestVersion: "9.0.0",
|
||
}
|
||
macOSApp6 := &fleet.VPPApp{
|
||
VPPAppTeam: fleet.VPPAppTeam{
|
||
VPPAppID: fleet.VPPAppID{
|
||
AdamID: "10",
|
||
Platform: fleet.MacOSPlatform,
|
||
},
|
||
},
|
||
Name: "App 10",
|
||
BundleIdentifier: "j-10",
|
||
IconURL: "https://example.com/images/10",
|
||
LatestVersion: "10.0.0",
|
||
}
|
||
|
||
expectedApps := []*fleet.VPPApp{macOSApp1, macOSApp2, macOSApp3, macOSApp4, macOSApp5, macOSApp6}
|
||
|
||
slices.SortFunc(expectedApps, func(a, b *fleet.VPPApp) int {
|
||
return strings.Compare(a.Name, b.Name)
|
||
})
|
||
|
||
expectedAppsByName := map[string]*fleet.VPPApp{
|
||
macOSApp1.Name: macOSApp1,
|
||
macOSApp2.Name: macOSApp2,
|
||
macOSApp3.Name: macOSApp3,
|
||
macOSApp4.Name: macOSApp4,
|
||
macOSApp5.Name: macOSApp5,
|
||
macOSApp6.Name: macOSApp6,
|
||
}
|
||
|
||
var addAppResp addAppStoreAppResponse
|
||
// Add remaining as non-self-service
|
||
var titleIDs []uint
|
||
for _, app := range expectedApps {
|
||
addAppResp = addAppStoreAppResponse{}
|
||
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps",
|
||
&addAppStoreAppRequest{TeamID: enrolledHost.TeamID, AppStoreID: app.AdamID, Platform: app.Platform},
|
||
http.StatusOK, &addAppResp)
|
||
titleIDs = append(titleIDs, getSoftwareTitleIDFromApp(app))
|
||
}
|
||
|
||
// Add VPP apps to setup experience
|
||
var swInstallResp putSetupExperienceSoftwareResponse
|
||
s.DoJSON("PUT", "/api/v1/fleet/setup_experience/software", putSetupExperienceSoftwareRequest{TeamID: *enrolledHost.TeamID, TitleIDs: titleIDs}, http.StatusOK, &swInstallResp)
|
||
|
||
// enroll the host
|
||
depURLToken := loadEnrollmentProfileDEPToken(t, s.ds)
|
||
mdmDevice := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, depURLToken)
|
||
mdmDevice.SerialNumber = teamDevice.SerialNumber
|
||
err := mdmDevice.Enroll()
|
||
require.NoError(t, err)
|
||
|
||
// run the worker to ensure fleet profiles
|
||
s.awaitTriggerProfileSchedule(t)
|
||
// run the worker to process the DEP enroll request
|
||
s.awaitRunAppleMDMWorkerSchedule()
|
||
s.runWorker()
|
||
|
||
var cmds []*micromdm.CommandPayload
|
||
cmd, err := mdmDevice.Idle()
|
||
require.NoError(t, err)
|
||
for cmd != nil {
|
||
if cmd.Command.RequestType == "DeclarativeManagement" {
|
||
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
|
||
require.NoError(t, err)
|
||
continue
|
||
}
|
||
|
||
var fullCmd micromdm.CommandPayload
|
||
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
|
||
|
||
// Can be useful for debugging
|
||
// switch cmd.Command.RequestType {
|
||
// case "InstallProfile":
|
||
// fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType, string(fullCmd.Command.InstallProfile.Payload))
|
||
// case "InstallEnterpriseApplication":
|
||
// if fullCmd.Command.InstallEnterpriseApplication.ManifestURL != nil {
|
||
// fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType, *fullCmd.Command.InstallEnterpriseApplication.ManifestURL)
|
||
// } else {
|
||
// fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType)
|
||
// }
|
||
// default:
|
||
// fmt.Println(">>>> device received command: ", cmd.Command.RequestType)
|
||
// }
|
||
|
||
cmds = append(cmds, &fullCmd)
|
||
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
|
||
require.NoError(t, err)
|
||
}
|
||
|
||
// expected commands: install fleetd (install enterprise), install profiles
|
||
// (custom one, fleetd configuration, fleet CA root)
|
||
require.Len(t, cmds, 4)
|
||
var installProfileCount, installEnterpriseCount, otherCount int
|
||
var profileCustomSeen, profileFleetdSeen, profileFleetCASeen, profileFileVaultSeen bool
|
||
for _, cmd := range cmds {
|
||
switch cmd.Command.RequestType {
|
||
case "InstallProfile":
|
||
installProfileCount++
|
||
switch {
|
||
case strings.Contains(string(cmd.Command.InstallProfile.Payload), "<string>I1</string>"):
|
||
profileCustomSeen = true
|
||
case strings.Contains(string(cmd.Command.InstallProfile.Payload), fmt.Sprintf("<string>%s</string>", mobileconfig.FleetdConfigPayloadIdentifier)):
|
||
profileFleetdSeen = true
|
||
case strings.Contains(string(cmd.Command.InstallProfile.Payload), fmt.Sprintf("<string>%s</string>", mobileconfig.FleetCARootConfigPayloadIdentifier)):
|
||
profileFleetCASeen = true
|
||
case strings.Contains(string(cmd.Command.InstallProfile.Payload), fmt.Sprintf("<string>%s</string", mobileconfig.FleetFileVaultPayloadIdentifier)) &&
|
||
strings.Contains(string(cmd.Command.InstallProfile.Payload), "ForceEnableInSetupAssistant"):
|
||
profileFileVaultSeen = true
|
||
}
|
||
|
||
case "InstallEnterpriseApplication":
|
||
installEnterpriseCount++
|
||
default:
|
||
otherCount++
|
||
}
|
||
}
|
||
require.Equal(t, 3, installProfileCount)
|
||
require.Equal(t, 1, installEnterpriseCount)
|
||
require.Equal(t, 0, otherCount)
|
||
require.True(t, profileCustomSeen)
|
||
require.True(t, profileFleetdSeen)
|
||
require.True(t, profileFleetCASeen)
|
||
require.False(t, profileFileVaultSeen)
|
||
|
||
// simulate fleetd being installed and the host being orbit-enrolled now
|
||
enrolledHost.OsqueryHostID = ptr.String(mdmDevice.UUID)
|
||
enrolledHost.UUID = mdmDevice.UUID
|
||
orbitKey := setOrbitEnrollment(t, enrolledHost, s.ds)
|
||
enrolledHost.OrbitNodeKey = &orbitKey
|
||
|
||
// there shouldn't be a worker Release Device pending job (we don't release that way anymore)
|
||
pending, err := s.ds.GetQueuedJobs(ctx, 1, time.Now().UTC().Add(time.Minute))
|
||
require.NoError(t, err)
|
||
require.Len(t, pending, 0)
|
||
|
||
// call the /status endpoint, the software and script should be pending
|
||
var statusResp fleet.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) // no bootstrap package involved
|
||
require.Nil(t, statusResp.Results.AccountConfiguration) // no SSO involved
|
||
require.Len(t, statusResp.Results.ConfigurationProfiles, 3) // fleetd config, root CA, custom profile
|
||
var profNames []string
|
||
var profStatuses []fleet.MDMDeliveryStatus
|
||
for _, prof := range statusResp.Results.ConfigurationProfiles {
|
||
profNames = append(profNames, prof.Name)
|
||
profStatuses = append(profStatuses, prof.Status)
|
||
}
|
||
require.ElementsMatch(t, []string{"N1", "Fleetd configuration", "Fleet root certificate authority (CA)"}, profNames)
|
||
require.ElementsMatch(t, []fleet.MDMDeliveryStatus{fleet.MDMDeliveryVerifying, fleet.MDMDeliveryVerifying, fleet.MDMDeliveryVerifying}, profStatuses)
|
||
|
||
// the software and script are still pending
|
||
require.NotNil(t, statusResp.Results.Script)
|
||
require.Equal(t, "script.sh", statusResp.Results.Script.Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Script.Status)
|
||
require.Len(t, statusResp.Results.Software, 6)
|
||
for _, software := range statusResp.Results.Software {
|
||
_, ok := expectedAppsByName[software.Name]
|
||
require.True(t, ok)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, software.Status)
|
||
require.NotNil(t, software.SoftwareTitleID)
|
||
require.NotZero(t, *software.SoftwareTitleID)
|
||
}
|
||
|
||
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
||
mysql.DumpTable(t, q, "host_vpp_software_installs", "command_uuid", "adam_id")
|
||
return nil
|
||
})
|
||
|
||
checkCommandsInFlight := func(expectedCount int) {
|
||
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
||
var count int
|
||
err := sqlx.GetContext(context.Background(), q, &count, "SELECT COUNT(*) FROM host_mdm_commands WHERE command_type = ?", fleet.VerifySoftwareInstallVPPPrefix)
|
||
require.NoError(t, err)
|
||
require.Equal(t, expectedCount, count)
|
||
return nil
|
||
})
|
||
}
|
||
|
||
type vppInstallOpts struct {
|
||
failOnInstall bool
|
||
appInstallTimeout bool
|
||
softwareResultList []fleet.Software
|
||
}
|
||
|
||
processVPPInstallOnClient := func(mdmClient *mdmtest.TestAppleMDMClient, opts *vppInstallOpts) string {
|
||
var installCmdUUID string
|
||
|
||
// Process the InstallApplication command
|
||
s.runWorker()
|
||
cmd, err := mdmClient.Idle()
|
||
require.NoError(t, err)
|
||
|
||
for cmd != nil {
|
||
var fullCmd micromdm.CommandPayload
|
||
switch cmd.Command.RequestType {
|
||
case "InstallApplication":
|
||
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
|
||
installCmdUUID = cmd.CommandUUID
|
||
if opts.failOnInstall {
|
||
t.Logf("Failed command UUID: %s", installCmdUUID)
|
||
cmd, err = mdmClient.Err(cmd.CommandUUID, []mdm.ErrorChain{{ErrorCode: 1234}})
|
||
require.NoError(t, err)
|
||
continue
|
||
}
|
||
|
||
cmd, err = mdmClient.Acknowledge(cmd.CommandUUID)
|
||
require.NoError(t, err)
|
||
case "InstalledApplicationList":
|
||
// If we are polling to verify the install, we should get an
|
||
// InstalledApplicationList command instead of an InstallApplication command.
|
||
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
|
||
_, err = mdmClient.AcknowledgeInstalledApplicationList(
|
||
mdmClient.UUID,
|
||
cmd.CommandUUID,
|
||
opts.softwareResultList,
|
||
)
|
||
require.NoError(t, err)
|
||
return ""
|
||
default:
|
||
require.Fail(t, "unexpected MDM command on client", cmd.Command.RequestType)
|
||
}
|
||
}
|
||
|
||
if opts.failOnInstall {
|
||
return installCmdUUID
|
||
}
|
||
|
||
if opts.appInstallTimeout {
|
||
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
||
_, err := q.ExecContext(context.Background(), "UPDATE nano_command_results SET updated_at = ? WHERE command_uuid = ?", time.Now().Add(-11*time.Minute), installCmdUUID)
|
||
return err
|
||
})
|
||
}
|
||
|
||
// Process the verification command (InstalledApplicationList)
|
||
s.runWorker()
|
||
// Check that there is now a verify command in flight
|
||
checkCommandsInFlight(1)
|
||
cmd, err = mdmClient.Idle()
|
||
require.NoError(t, err)
|
||
for cmd != nil {
|
||
var fullCmd micromdm.CommandPayload
|
||
switch cmd.Command.RequestType {
|
||
case "InstalledApplicationList":
|
||
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
|
||
cmd, err = mdmClient.AcknowledgeInstalledApplicationList(
|
||
mdmClient.UUID,
|
||
cmd.CommandUUID,
|
||
opts.softwareResultList,
|
||
)
|
||
require.NoError(t, err)
|
||
default:
|
||
require.Fail(t, "unexpected MDM command on client", cmd.Command.RequestType)
|
||
}
|
||
}
|
||
|
||
return installCmdUUID
|
||
}
|
||
|
||
// Simulate successful installation on the host
|
||
opts := &vppInstallOpts{}
|
||
opts.appInstallTimeout = false
|
||
opts.failOnInstall = false
|
||
// App 1 is installed now
|
||
opts.softwareResultList = []fleet.Software{
|
||
{
|
||
Name: macOSApp1.Name,
|
||
BundleIdentifier: macOSApp1.BundleIdentifier,
|
||
Version: macOSApp1.LatestVersion,
|
||
Installed: true,
|
||
},
|
||
}
|
||
processVPPInstallOnClient(mdmDevice, opts)
|
||
|
||
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *enrolledHost.OrbitNodeKey)), http.StatusOK, &statusResp)
|
||
|
||
require.NotNil(t, statusResp.Results.Script)
|
||
require.Equal(t, "script.sh", statusResp.Results.Script.Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Script.Status)
|
||
require.Len(t, statusResp.Results.Software, 6)
|
||
for _, software := range statusResp.Results.Software {
|
||
_, ok := expectedAppsByName[software.Name]
|
||
require.True(t, ok)
|
||
if software.Name == macOSApp1.Name {
|
||
require.Equal(t, fleet.SetupExperienceStatusSuccess, software.Status)
|
||
} else {
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, software.Status)
|
||
}
|
||
require.NotNil(t, software.SoftwareTitleID)
|
||
require.NotZero(t, *software.SoftwareTitleID)
|
||
}
|
||
|
||
// Only 2 apps have an installation attempt at this point
|
||
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
||
var count int
|
||
err := sqlx.GetContext(context.Background(), q, &count, "SELECT COUNT(*) FROM host_vpp_software_installs")
|
||
require.NoError(t, err)
|
||
require.Equal(t, 2, count)
|
||
return nil
|
||
})
|
||
|
||
installedApps := map[string]struct{}{
|
||
macOSApp1.Name: {},
|
||
}
|
||
|
||
for _, app := range expectedApps[1:] {
|
||
opts.softwareResultList = append(opts.softwareResultList, fleet.Software{
|
||
Name: app.Name,
|
||
BundleIdentifier: app.BundleIdentifier,
|
||
Version: app.LatestVersion,
|
||
Installed: true,
|
||
})
|
||
|
||
installedApps[app.Name] = struct{}{}
|
||
|
||
processVPPInstallOnClient(mdmDevice, opts)
|
||
|
||
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *enrolledHost.OrbitNodeKey)), http.StatusOK, &statusResp)
|
||
|
||
require.NotNil(t, statusResp.Results.Script)
|
||
require.Equal(t, "script.sh", statusResp.Results.Script.Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Script.Status)
|
||
require.Len(t, statusResp.Results.Software, 6)
|
||
for _, software := range statusResp.Results.Software {
|
||
_, ok := expectedAppsByName[software.Name]
|
||
require.True(t, ok)
|
||
_, shouldBeInstalled := installedApps[software.Name]
|
||
if shouldBeInstalled {
|
||
require.Equalf(t, fleet.SetupExperienceStatusSuccess, software.Status, "software %s should have succeeded", software.Name)
|
||
}
|
||
require.NotNil(t, software.SoftwareTitleID)
|
||
require.NotZero(t, *software.SoftwareTitleID)
|
||
}
|
||
|
||
}
|
||
}
|
||
|
||
func (s *integrationMDMTestSuite) TestSetupExperienceEndpointsWithPlatform() {
|
||
t := s.T()
|
||
ctx := context.Background()
|
||
|
||
team1, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team 1"})
|
||
require.NoError(t, err)
|
||
|
||
// Add a macOS software to the setup experience on team 1.
|
||
payloadDummy := &fleet.UploadSoftwareInstallerPayload{
|
||
InstallScript: "install",
|
||
Filename: "dummy_installer.pkg",
|
||
Title: "DummyApp",
|
||
TeamID: &team1.ID,
|
||
}
|
||
s.uploadSoftwareInstaller(t, payloadDummy, http.StatusOK, "")
|
||
titleID := getSoftwareTitleID(t, s.ds, payloadDummy.Title, "apps")
|
||
var swInstallResp putSetupExperienceSoftwareResponse
|
||
s.DoJSON("PUT", "/api/v1/fleet/setup_experience/software", putSetupExperienceSoftwareRequest{
|
||
Platform: "macos",
|
||
TeamID: team1.ID,
|
||
TitleIDs: []uint{titleID},
|
||
}, http.StatusOK, &swInstallResp)
|
||
|
||
// Get "Setup experience" items using platform and the endpoint without platform that we cannot remove (for backwards compatibility).
|
||
var respGetSetupExperience getSetupExperienceSoftwareResponse
|
||
s.DoJSON("GET", "/api/latest/fleet/setup_experience/software", getSetupExperienceSoftwareRequest{}, http.StatusOK, &respGetSetupExperience, "team_id", fmt.Sprint(team1.ID))
|
||
noPlatformTitles := respGetSetupExperience.SoftwareTitles
|
||
require.Len(t, noPlatformTitles, 1)
|
||
respGetSetupExperience = getSetupExperienceSoftwareResponse{}
|
||
s.DoJSON("GET", "/api/latest/fleet/setup_experience/software", getSetupExperienceSoftwareRequest{},
|
||
http.StatusOK,
|
||
&respGetSetupExperience,
|
||
"platform", "macos",
|
||
"team_id", fmt.Sprint(team1.ID),
|
||
)
|
||
macOSPlatformTitles := respGetSetupExperience.SoftwareTitles
|
||
require.Equal(t, macOSPlatformTitles, noPlatformTitles)
|
||
|
||
// Test invalid platform in GET and PUT.
|
||
res := s.DoRawWithHeaders("PUT", "/api/v1/fleet/setup_experience/software", []byte(`{"platform": "foobar", "team_id": 0}`), http.StatusBadRequest, nil)
|
||
errMsg := extractServerErrorText(res.Body)
|
||
require.NoError(t, res.Body.Close())
|
||
require.Contains(t, errMsg, "platform \"foobar\" unsupported, platform must be one of \"macos\", \"ios\", \"ipados\", \"windows\", \"linux\", \"android\"")
|
||
res = s.DoRawWithHeaders("GET", "/api/v1/fleet/setup_experience/software?platform=foobar&team_id=0", nil, http.StatusBadRequest, nil)
|
||
errMsg = extractServerErrorText(res.Body)
|
||
require.NoError(t, res.Body.Close())
|
||
require.Contains(t, errMsg, "platform \"foobar\" unsupported, platform must be one of \"macos\", \"ios\", \"ipados\", \"windows\", \"linux\", \"android\"")
|
||
}
|
||
|
||
func (s *integrationMDMTestSuite) TestSetupExperienceVPPCRUD() {
|
||
t := s.T()
|
||
ctx := context.Background()
|
||
|
||
team, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team 1"})
|
||
require.NoError(t, err)
|
||
|
||
// Just for testing isolation
|
||
otherTeam, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team 2"})
|
||
require.NoError(t, err)
|
||
|
||
orgName := "Fleet Device Management Inc."
|
||
token := "mycooltoken"
|
||
expTime := time.Now().Add(200 * time.Hour).UTC().Round(time.Second)
|
||
expDate := expTime.Format(fleet.VPPTimeFormat)
|
||
tokenJSON := fmt.Sprintf(`{"expDate":"%s","token":"%s","orgName":"%s"}`, expDate, token, orgName)
|
||
dev_mode.SetOverride("FLEET_DEV_VPP_URL", s.appleVPPConfigSrv.URL, t)
|
||
var validToken uploadVPPTokenResponse
|
||
s.uploadDataViaForm("/api/latest/fleet/vpp_tokens", "token", "token.vpptoken", []byte(base64.StdEncoding.EncodeToString([]byte(tokenJSON))), http.StatusAccepted, "", &validToken)
|
||
|
||
var getVPPTokenResp getVPPTokensResponse
|
||
s.DoJSON("GET", "/api/latest/fleet/vpp_tokens", &getVPPTokensRequest{}, http.StatusOK, &getVPPTokenResp)
|
||
|
||
// Associate team to the VPP token.
|
||
var resPatchVPP patchVPPTokensTeamsResponse
|
||
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", getVPPTokenResp.Tokens[0].ID), patchVPPTokensTeamsRequest{TeamIDs: []uint{team.ID, otherTeam.ID}}, http.StatusOK, &resPatchVPP)
|
||
|
||
// app 1 macOS only
|
||
macOSApp1 := &fleet.VPPApp{
|
||
VPPAppTeam: fleet.VPPAppTeam{
|
||
VPPAppID: fleet.VPPAppID{
|
||
AdamID: "1",
|
||
Platform: fleet.MacOSPlatform,
|
||
},
|
||
},
|
||
Name: "App 1",
|
||
BundleIdentifier: "a-1",
|
||
IconURL: "https://example.com/images/1",
|
||
LatestVersion: "1.0.0",
|
||
}
|
||
// App 2 supports macOS, iOS, iPadOS
|
||
macOSApp2 := &fleet.VPPApp{
|
||
VPPAppTeam: fleet.VPPAppTeam{
|
||
VPPAppID: fleet.VPPAppID{
|
||
AdamID: "2",
|
||
Platform: fleet.MacOSPlatform,
|
||
},
|
||
},
|
||
Name: "App 2",
|
||
BundleIdentifier: "b-2",
|
||
IconURL: "https://example.com/images/2",
|
||
LatestVersion: "2.0.0",
|
||
}
|
||
iOSApp2 := &fleet.VPPApp{
|
||
VPPAppTeam: fleet.VPPAppTeam{
|
||
VPPAppID: fleet.VPPAppID{
|
||
AdamID: "2",
|
||
Platform: fleet.IOSPlatform,
|
||
},
|
||
},
|
||
Name: "App 2",
|
||
BundleIdentifier: "b-2",
|
||
IconURL: "https://example.com/images/2",
|
||
LatestVersion: "2.0.0",
|
||
}
|
||
iPadOSApp2 := &fleet.VPPApp{
|
||
VPPAppTeam: fleet.VPPAppTeam{
|
||
VPPAppID: fleet.VPPAppID{
|
||
AdamID: "2",
|
||
Platform: fleet.IPadOSPlatform,
|
||
},
|
||
},
|
||
Name: "App 2",
|
||
BundleIdentifier: "b-2",
|
||
IconURL: "https://example.com/images/2",
|
||
LatestVersion: "2.0.0",
|
||
}
|
||
|
||
// App 3 is iPadOS only
|
||
iPadOSApp3 := &fleet.VPPApp{
|
||
VPPAppTeam: fleet.VPPAppTeam{
|
||
VPPAppID: fleet.VPPAppID{
|
||
AdamID: "3",
|
||
Platform: fleet.IPadOSPlatform,
|
||
},
|
||
},
|
||
Name: "App 3",
|
||
BundleIdentifier: "c-3",
|
||
IconURL: "https://example.com/images/3",
|
||
LatestVersion: "3.0.0",
|
||
}
|
||
|
||
expectedApps := []*fleet.VPPApp{macOSApp1, macOSApp2, iOSApp2, iPadOSApp2, iPadOSApp3}
|
||
|
||
var addAppResp addAppStoreAppResponse
|
||
// Add apps
|
||
getSoftwareTitleIDFromApp := func(app *fleet.VPPApp) uint {
|
||
var titleID uint
|
||
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
||
ctx := context.Background()
|
||
return sqlx.GetContext(ctx, q, &titleID, `SELECT title_id FROM vpp_apps WHERE adam_id = ? AND platform = ?`, app.AdamID, app.Platform)
|
||
})
|
||
require.NoError(t, err)
|
||
|
||
return titleID
|
||
}
|
||
|
||
titleIDsByApp := make(map[*fleet.VPPApp]uint)
|
||
for _, app := range expectedApps {
|
||
addAppResp = addAppStoreAppResponse{}
|
||
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps",
|
||
&addAppStoreAppRequest{TeamID: &team.ID, AppStoreID: app.AdamID, Platform: app.Platform},
|
||
http.StatusOK, &addAppResp)
|
||
titleIDsByApp[app] = getSoftwareTitleIDFromApp(app)
|
||
|
||
// Add apps to the other team as well so that they are available but should not show up in setup experience
|
||
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps",
|
||
&addAppStoreAppRequest{TeamID: &otherTeam.ID, AppStoreID: app.AdamID, Platform: app.Platform},
|
||
http.StatusOK, &addAppResp)
|
||
}
|
||
|
||
// Helper function for inspecting returned list of software marked for setup experience
|
||
getReturnedSetupExperienceTitleIDs := func(titles []fleet.SoftwareTitleListResult) []uint {
|
||
var titleIDs []uint
|
||
for _, title := range titles {
|
||
if (title.AppStoreApp != nil && title.AppStoreApp.InstallDuringSetup != nil && *title.AppStoreApp.InstallDuringSetup == true) ||
|
||
(title.SoftwarePackage != nil && title.SoftwarePackage.InstallDuringSetup != nil && *title.SoftwarePackage.InstallDuringSetup == true) {
|
||
titleIDs = append(titleIDs, title.ID)
|
||
}
|
||
}
|
||
return titleIDs
|
||
}
|
||
|
||
checkSetupExperienceSoftware := func(t *testing.T, platform string, teamID uint, expectedTitleIDs []uint) {
|
||
var respGetSetupExperience getSetupExperienceSoftwareResponse
|
||
s.DoJSON("GET", "/api/latest/fleet/setup_experience/software", getSetupExperienceSoftwareRequest{},
|
||
http.StatusOK,
|
||
&respGetSetupExperience,
|
||
"platform", platform,
|
||
"team_id", fmt.Sprint(teamID),
|
||
)
|
||
assert.ElementsMatch(t, getReturnedSetupExperienceTitleIDs(respGetSetupExperience.SoftwareTitles), expectedTitleIDs)
|
||
}
|
||
|
||
putSetupExperienceSoftwareForPlatform := func(t *testing.T, platform string, teamID uint, titleIDs []uint) {
|
||
var swInstallResp putSetupExperienceSoftwareResponse
|
||
s.DoJSON("PUT", "/api/v1/fleet/setup_experience/software", putSetupExperienceSoftwareRequest{
|
||
Platform: platform,
|
||
TeamID: teamID,
|
||
TitleIDs: titleIDs,
|
||
}, http.StatusOK, &swInstallResp)
|
||
}
|
||
|
||
// Set the 2 apps for macOS
|
||
putSetupExperienceSoftwareForPlatform(t, "macos", team.ID, []uint{titleIDsByApp[macOSApp1], titleIDsByApp[macOSApp2]})
|
||
|
||
// Should return both of the items we set for macOS
|
||
checkSetupExperienceSoftware(t, "macos", team.ID, []uint{titleIDsByApp[macOSApp1], titleIDsByApp[macOSApp2]})
|
||
|
||
// Should return nothing for iOS/iPadOS
|
||
checkSetupExperienceSoftware(t, "ios", team.ID, []uint{})
|
||
checkSetupExperienceSoftware(t, "ipados", team.ID, []uint{})
|
||
|
||
// Should return nothing for macOS on other team
|
||
checkSetupExperienceSoftware(t, "macos", otherTeam.ID, []uint{})
|
||
|
||
// Add an app for iOS
|
||
putSetupExperienceSoftwareForPlatform(t, "ios", team.ID, []uint{titleIDsByApp[iOSApp2]})
|
||
|
||
// Fetch iOS apps for the team and now it should be listed
|
||
checkSetupExperienceSoftware(t, "ios", team.ID, []uint{titleIDsByApp[iOSApp2]})
|
||
|
||
// Should still return nothing for iPadOS
|
||
checkSetupExperienceSoftware(t, "ipados", team.ID, []uint{})
|
||
|
||
// Should still return both of the items we set for macOS
|
||
checkSetupExperienceSoftware(t, "macos", team.ID, []uint{titleIDsByApp[macOSApp1], titleIDsByApp[macOSApp2]})
|
||
|
||
// Add apps for iPadOS
|
||
putSetupExperienceSoftwareForPlatform(t, "ipados", team.ID, []uint{titleIDsByApp[iPadOSApp2], titleIDsByApp[iPadOSApp3]})
|
||
|
||
// Fetch iPadOS apps for the team and now they should be listed
|
||
checkSetupExperienceSoftware(t, "ipados", team.ID, []uint{titleIDsByApp[iPadOSApp2], titleIDsByApp[iPadOSApp3]})
|
||
|
||
// Should return nothing for iOS/iPadOS/macOS on the other team
|
||
checkSetupExperienceSoftware(t, "ios", otherTeam.ID, []uint{})
|
||
checkSetupExperienceSoftware(t, "ipados", otherTeam.ID, []uint{})
|
||
checkSetupExperienceSoftware(t, "macos", otherTeam.ID, []uint{})
|
||
|
||
// try to add an ipadOS app to macOS and iOS, both should fail
|
||
res := s.DoRaw("PUT", "/api/v1/fleet/setup_experience/software", []byte(fmt.Sprintf(`{"platform": "macos", "team_id": %d, "software_title_ids": [%d]}`, team.ID, titleIDsByApp[iPadOSApp2])), http.StatusBadRequest)
|
||
errMsg := extractServerErrorText(res.Body)
|
||
require.NoError(t, res.Body.Close())
|
||
assert.Contains(t, errMsg, "invalid platform for requested AppStoreApp")
|
||
|
||
res = s.DoRaw("PUT", "/api/v1/fleet/setup_experience/software", []byte(fmt.Sprintf(`{"platform": "ios", "team_id": %d, "software_title_ids": [%d]}`, team.ID, titleIDsByApp[iPadOSApp2])), http.StatusBadRequest)
|
||
errMsg = extractServerErrorText(res.Body)
|
||
require.NoError(t, res.Body.Close())
|
||
assert.Contains(t, errMsg, "invalid platform for requested AppStoreApp")
|
||
|
||
// Lists should be unchanged after the failed attempts
|
||
checkSetupExperienceSoftware(t, "macos", team.ID, []uint{titleIDsByApp[macOSApp1], titleIDsByApp[macOSApp2]})
|
||
checkSetupExperienceSoftware(t, "ios", team.ID, []uint{titleIDsByApp[iOSApp2]})
|
||
|
||
// Clear iPadOS and verify macOS and iPadOS are unaffected
|
||
putSetupExperienceSoftwareForPlatform(t, "ipados", team.ID, []uint{})
|
||
|
||
// iPadOS should be empty now
|
||
checkSetupExperienceSoftware(t, "ipados", team.ID, []uint{})
|
||
|
||
// macOS/iPadOS lists should be unchanged
|
||
checkSetupExperienceSoftware(t, "macos", team.ID, []uint{titleIDsByApp[macOSApp1], titleIDsByApp[macOSApp2]})
|
||
checkSetupExperienceSoftware(t, "ios", team.ID, []uint{titleIDsByApp[iOSApp2]})
|
||
}
|
||
|
||
func (s *integrationMDMTestSuite) TestSetupExperienceIOSAndIPadOS() {
|
||
t := s.T()
|
||
s.setSkipWorkerJobs(t)
|
||
ctx := context.Background()
|
||
abmOrgName := "fleet_ade_ios_ipados_team_test"
|
||
|
||
s.enableABM(abmOrgName)
|
||
|
||
team, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team 1"})
|
||
require.NoError(t, err)
|
||
|
||
var acResp appConfigResponse
|
||
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(fmt.Sprintf(`{
|
||
"mdm": {
|
||
"apple_business_manager": [{
|
||
"organization_name": %q,
|
||
"macos_team": %q,
|
||
"ios_team": %q,
|
||
"ipados_team": %q
|
||
}]
|
||
}
|
||
}`, abmOrgName, team.Name, team.Name, team.Name)), http.StatusOK, &acResp)
|
||
|
||
orgName := "Fleet Device Management Inc."
|
||
token := "mycooltoken"
|
||
expTime := time.Now().Add(200 * time.Hour).UTC().Round(time.Second)
|
||
expDate := expTime.Format(fleet.VPPTimeFormat)
|
||
tokenJSON := fmt.Sprintf(`{"expDate":"%s","token":"%s","orgName":"%s"}`, expDate, token, orgName)
|
||
dev_mode.SetOverride("FLEET_DEV_VPP_URL", s.appleVPPConfigSrv.URL, t)
|
||
var validToken uploadVPPTokenResponse
|
||
s.uploadDataViaForm("/api/latest/fleet/vpp_tokens", "token", "token.vpptoken", []byte(base64.StdEncoding.EncodeToString([]byte(tokenJSON))), http.StatusAccepted, "", &validToken)
|
||
|
||
var getVPPTokenResp getVPPTokensResponse
|
||
s.DoJSON("GET", "/api/latest/fleet/vpp_tokens", &getVPPTokensRequest{}, http.StatusOK, &getVPPTokenResp)
|
||
|
||
// Associate team to the VPP token.
|
||
var resPatchVPP patchVPPTokensTeamsResponse
|
||
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", getVPPTokenResp.Tokens[0].ID), patchVPPTokensTeamsRequest{TeamIDs: []uint{team.ID}}, http.StatusOK, &resPatchVPP)
|
||
|
||
// app 1 macOS only
|
||
macOSApp1 := &fleet.VPPApp{
|
||
VPPAppTeam: fleet.VPPAppTeam{
|
||
VPPAppID: fleet.VPPAppID{
|
||
AdamID: "1",
|
||
Platform: fleet.MacOSPlatform,
|
||
},
|
||
},
|
||
Name: "App 1",
|
||
BundleIdentifier: "a-1",
|
||
IconURL: "https://example.com/images/1",
|
||
LatestVersion: "1.0.0",
|
||
}
|
||
// App 2 supports macOS, iOS, iPadOS
|
||
macOSApp2 := &fleet.VPPApp{
|
||
VPPAppTeam: fleet.VPPAppTeam{
|
||
VPPAppID: fleet.VPPAppID{
|
||
AdamID: "2",
|
||
Platform: fleet.MacOSPlatform,
|
||
},
|
||
},
|
||
Name: "App 2",
|
||
BundleIdentifier: "b-2",
|
||
IconURL: "https://example.com/images/2",
|
||
LatestVersion: "2.0.0",
|
||
}
|
||
iOSApp2 := &fleet.VPPApp{
|
||
VPPAppTeam: fleet.VPPAppTeam{
|
||
VPPAppID: fleet.VPPAppID{
|
||
AdamID: "2",
|
||
Platform: fleet.IOSPlatform,
|
||
},
|
||
},
|
||
Name: "App 2",
|
||
BundleIdentifier: "b-2",
|
||
IconURL: "https://example.com/images/2",
|
||
LatestVersion: "2.0.0",
|
||
}
|
||
iPadOSApp2 := &fleet.VPPApp{
|
||
VPPAppTeam: fleet.VPPAppTeam{
|
||
VPPAppID: fleet.VPPAppID{
|
||
AdamID: "2",
|
||
Platform: fleet.IPadOSPlatform,
|
||
},
|
||
},
|
||
Name: "App 2",
|
||
BundleIdentifier: "b-2",
|
||
IconURL: "https://example.com/images/2",
|
||
LatestVersion: "2.0.0",
|
||
}
|
||
|
||
// App 3 is iPadOS only
|
||
iPadOSApp3 := &fleet.VPPApp{
|
||
VPPAppTeam: fleet.VPPAppTeam{
|
||
VPPAppID: fleet.VPPAppID{
|
||
AdamID: "3",
|
||
Platform: fleet.IPadOSPlatform,
|
||
},
|
||
},
|
||
Name: "App 3",
|
||
BundleIdentifier: "c-3",
|
||
IconURL: "https://example.com/images/3",
|
||
LatestVersion: "3.0.0",
|
||
}
|
||
|
||
expectedApps := []*fleet.VPPApp{macOSApp1, macOSApp2, iOSApp2, iPadOSApp2, iPadOSApp3}
|
||
|
||
var addAppResp addAppStoreAppResponse
|
||
// Add apps
|
||
getSoftwareTitleIDFromApp := func(app *fleet.VPPApp) uint {
|
||
var titleID uint
|
||
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
||
ctx := context.Background()
|
||
return sqlx.GetContext(ctx, q, &titleID, `SELECT title_id FROM vpp_apps WHERE adam_id = ? AND platform = ?`, app.AdamID, app.Platform)
|
||
})
|
||
require.NoError(t, err)
|
||
|
||
return titleID
|
||
}
|
||
|
||
titleIDsByApp := make(map[*fleet.VPPApp]uint)
|
||
for _, app := range expectedApps {
|
||
addAppResp = addAppStoreAppResponse{}
|
||
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps",
|
||
&addAppStoreAppRequest{TeamID: &team.ID, AppStoreID: app.AdamID, Platform: app.Platform},
|
||
http.StatusOK, &addAppResp)
|
||
titleIDsByApp[app] = getSoftwareTitleIDFromApp(app)
|
||
}
|
||
|
||
putSetupExperienceSoftwareForPlatform := func(t *testing.T, platform string, teamID uint, titleIDs []uint) {
|
||
var swInstallResp putSetupExperienceSoftwareResponse
|
||
s.DoJSON("PUT", "/api/v1/fleet/setup_experience/software", putSetupExperienceSoftwareRequest{
|
||
Platform: platform,
|
||
TeamID: teamID,
|
||
TitleIDs: titleIDs,
|
||
}, http.StatusOK, &swInstallResp)
|
||
}
|
||
|
||
// Set the 2 apps for macOS
|
||
putSetupExperienceSoftwareForPlatform(t, "macos", team.ID, []uint{titleIDsByApp[macOSApp1], titleIDsByApp[macOSApp2]})
|
||
|
||
// Add an app for iOS
|
||
putSetupExperienceSoftwareForPlatform(t, "ios", team.ID, []uint{titleIDsByApp[iOSApp2]})
|
||
|
||
// Add apps for iPadOS
|
||
putSetupExperienceSoftwareForPlatform(t, "ipados", team.ID, []uint{titleIDsByApp[iPadOSApp2], titleIDsByApp[iPadOSApp3]})
|
||
|
||
// Add a profile
|
||
teamProfile := mobileconfigForTest("N1", "I1")
|
||
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{teamProfile}}, http.StatusNoContent, "team_id", fmt.Sprint(team.ID))
|
||
|
||
devices := []godep.Device{
|
||
{
|
||
Model: "iPad Pro 12.9\" (Wi-Fi Only - 3rd Gen)",
|
||
OS: "iPadOS",
|
||
DeviceFamily: "iPad",
|
||
OpType: "added",
|
||
SerialNumber: "ipad-123456",
|
||
},
|
||
{
|
||
Model: "iPhone 16 Pro",
|
||
OS: "iOS",
|
||
DeviceFamily: "iPhone",
|
||
OpType: "added",
|
||
SerialNumber: "iphone-123456",
|
||
},
|
||
}
|
||
|
||
s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, devices[0].SerialNumber, devices[1].SerialNumber)
|
||
|
||
vppAppIDsByDeviceFamily := map[string][]*fleet.VPPApp{
|
||
"iPhone": {iOSApp2},
|
||
"iPad": {iPadOSApp2, iPadOSApp3},
|
||
}
|
||
|
||
for _, enableReleaseManually := range []bool{false, true} {
|
||
for _, enrollmentProfileFromDEPUsingPost := range []bool{false, true} {
|
||
for _, mdmMigrationDeadline := range []bool{false, true} {
|
||
for _, device := range devices {
|
||
t.Run(fmt.Sprintf("%sSetupExperience;enableReleaseManually=%t;EnrollmentProfileFromDEPUsingPost=%t;WithMDMMigrationDeadline=%t", device.DeviceFamily, enableReleaseManually, enrollmentProfileFromDEPUsingPost, mdmMigrationDeadline), func(t *testing.T) {
|
||
if mdmMigrationDeadline {
|
||
deadline := time.Now().Add(24 * time.Hour)
|
||
device.MDMMigrationDeadline = &deadline
|
||
} else {
|
||
device.MDMMigrationDeadline = nil
|
||
}
|
||
s.runDEPEnrollReleaseMobileDeviceWithVPPTest(t, device, DEPEnrollMobileTestOpts{
|
||
ABMOrg: abmOrgName,
|
||
EnableReleaseManually: enableReleaseManually,
|
||
TeamID: &team.ID,
|
||
CustomProfileIdent: "N1",
|
||
EnrollmentProfileFromDEPUsingPost: enrollmentProfileFromDEPUsingPost,
|
||
VppAppsToInstall: vppAppIDsByDeviceFamily[device.DeviceFamily],
|
||
})
|
||
})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
type DEPEnrollMobileTestOpts struct {
|
||
ABMOrg string
|
||
EnableReleaseManually bool
|
||
TeamID *uint
|
||
CustomProfileIdent string
|
||
EnrollmentProfileFromDEPUsingPost bool
|
||
VppAppsToInstall []*fleet.VPPApp
|
||
}
|
||
|
||
func (s *integrationMDMTestSuite) runDEPEnrollReleaseMobileDeviceWithVPPTest(t *testing.T, device godep.Device, opts DEPEnrollMobileTestOpts) {
|
||
ctx := context.Background()
|
||
|
||
// set the enable release device manually option
|
||
payload := map[string]any{
|
||
"enable_release_device_manually": opts.EnableReleaseManually,
|
||
"manual_agent_install": false,
|
||
}
|
||
if opts.TeamID != nil {
|
||
payload["team_id"] = *opts.TeamID
|
||
}
|
||
|
||
s.Do("PATCH", "/api/latest/fleet/setup_experience", json.RawMessage(jsonMustMarshal(t, payload)), http.StatusNoContent)
|
||
t.Cleanup(func() {
|
||
// Get back to the default state.
|
||
payload["enable_release_device_manually"] = false
|
||
s.Do("PATCH", "/api/latest/fleet/setup_experience", json.RawMessage(jsonMustMarshal(t, payload)), http.StatusNoContent)
|
||
})
|
||
|
||
// query all hosts - none yet
|
||
listHostsRes := listHostsResponse{}
|
||
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
|
||
require.Empty(t, listHostsRes.Hosts)
|
||
|
||
s.pushProvider.PushFunc = func(_ context.Context, pushes []*mdm.Push) (map[string]*push.Response, error) {
|
||
return map[string]*push.Response{}, nil
|
||
}
|
||
|
||
s.mockDEPResponse(opts.ABMOrg, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
encoder := json.NewEncoder(w)
|
||
switch r.URL.Path {
|
||
case "/session":
|
||
err := encoder.Encode(map[string]string{"auth_session_token": "xyz"})
|
||
require.NoError(t, err)
|
||
case "/profile":
|
||
err := encoder.Encode(godep.ProfileResponse{ProfileUUID: uuid.New().String()})
|
||
require.NoError(t, err)
|
||
case "/server/devices":
|
||
err := encoder.Encode(godep.DeviceResponse{Devices: []godep.Device{device}})
|
||
require.NoError(t, err)
|
||
case "/devices/sync":
|
||
// This endpoint is polled over time to sync devices from
|
||
// ABM, send a repeated serial and a new one
|
||
err := encoder.Encode(godep.DeviceResponse{Devices: []godep.Device{device}, Cursor: "foo"})
|
||
require.NoError(t, err)
|
||
case "/profile/devices":
|
||
b, err := io.ReadAll(r.Body)
|
||
require.NoError(t, err)
|
||
|
||
var prof profileAssignmentReq
|
||
require.NoError(t, json.Unmarshal(b, &prof))
|
||
|
||
var resp godep.ProfileResponse
|
||
resp.ProfileUUID = prof.ProfileUUID
|
||
resp.Devices = make(map[string]string, len(prof.Devices))
|
||
for _, device := range prof.Devices {
|
||
resp.Devices[device] = string(fleet.DEPAssignProfileResponseSuccess)
|
||
}
|
||
err = encoder.Encode(resp)
|
||
require.NoError(t, err)
|
||
default:
|
||
_, _ = w.Write([]byte(`{}`))
|
||
}
|
||
}))
|
||
|
||
// trigger a profile sync
|
||
s.runDEPSchedule()
|
||
|
||
listHostsRes = listHostsResponse{}
|
||
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
|
||
require.Len(t, listHostsRes.Hosts, 1)
|
||
require.Equal(t, listHostsRes.Hosts[0].HardwareSerial, device.SerialNumber)
|
||
enrolledHost := listHostsRes.Hosts[0].Host
|
||
|
||
t.Cleanup(func() {
|
||
// delete the enrolled host
|
||
err := s.ds.DeleteHost(ctx, enrolledHost.ID)
|
||
require.NoError(t, err)
|
||
// clear out any left behind jobs
|
||
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
||
_, err := q.ExecContext(ctx, `DELETE FROM jobs`)
|
||
return err
|
||
})
|
||
})
|
||
|
||
// enroll the host
|
||
depURLToken := loadEnrollmentProfileDEPToken(t, s.ds)
|
||
clientOpts := make([]mdmtest.TestMDMAppleClientOption, 0)
|
||
if opts.EnrollmentProfileFromDEPUsingPost {
|
||
clientOpts = append(clientOpts, mdmtest.WithEnrollmentProfileFromDEPUsingPost())
|
||
}
|
||
mdmDevice := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, depURLToken, clientOpts...)
|
||
switch device.DeviceFamily {
|
||
case "iPhone":
|
||
mdmDevice.Model = "iPhone14,6"
|
||
case "iPad":
|
||
mdmDevice.Model = "iPad8,7"
|
||
default:
|
||
// Only expecting mobile devices for this test
|
||
t.Fatalf("unexpected device family: %s", device.DeviceFamily)
|
||
}
|
||
mdmDevice.SerialNumber = device.SerialNumber
|
||
err := mdmDevice.Enroll()
|
||
require.NoError(t, err)
|
||
|
||
// The host should be awaiting configuration
|
||
awaitingConfiguration, err := s.ds.GetHostAwaitingConfiguration(ctx, mdmDevice.UUID)
|
||
require.NoError(t, err)
|
||
require.True(t, awaitingConfiguration)
|
||
|
||
// run the cron to assign configuration profiles
|
||
s.awaitTriggerProfileSchedule(t)
|
||
|
||
// run the worker to process the DEP enroll request
|
||
s.awaitRunAppleMDMWorkerSchedule()
|
||
s.runWorker()
|
||
|
||
var cmds []*micromdm.CommandPayload
|
||
cmd, err := mdmDevice.Idle()
|
||
require.NoError(t, err)
|
||
|
||
// For reporting back via InstalledApplicationList
|
||
installedVPPApps := make([]fleet.Software, 0, len(opts.VppAppsToInstall))
|
||
// For verifying number of installs
|
||
installedApps := make(map[string]int, len(opts.VppAppsToInstall))
|
||
|
||
var installProfileCount, installAppCount, refetchVerifyCount, otherCount int
|
||
var profileCustomSeen, profileFleetCASeen, unexpectedProfileSeen bool
|
||
|
||
// Can be useful for debugging
|
||
logCommands := true
|
||
for cmd != nil {
|
||
if cmd.Command.RequestType == "DeclarativeManagement" {
|
||
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
|
||
require.NoError(t, err)
|
||
continue
|
||
}
|
||
|
||
var fullCmd micromdm.CommandPayload
|
||
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
|
||
|
||
if strings.HasPrefix(cmd.CommandUUID, fleet.RefetchAppsCommandUUID()) {
|
||
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
|
||
require.NoError(t, err)
|
||
continue
|
||
}
|
||
|
||
switch cmd.Command.RequestType {
|
||
case "InstallProfile":
|
||
if logCommands {
|
||
fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType, string(fullCmd.Command.InstallProfile.Payload))
|
||
}
|
||
installProfileCount++
|
||
if strings.Contains(string(fullCmd.Command.InstallProfile.Payload), //nolint:gocritic // ignore ifElseChain
|
||
fmt.Sprintf("<string>%s</string>", opts.CustomProfileIdent)) {
|
||
profileCustomSeen = true
|
||
} else if strings.Contains(string(fullCmd.Command.InstallProfile.Payload), fmt.Sprintf("<string>%s</string>", mobileconfig.FleetdConfigPayloadIdentifier)) {
|
||
unexpectedProfileSeen = true
|
||
} else if strings.Contains(string(fullCmd.Command.InstallProfile.Payload), fmt.Sprintf("<string>%s</string>", mobileconfig.FleetCARootConfigPayloadIdentifier)) {
|
||
profileFleetCASeen = true
|
||
} else if strings.Contains(string(fullCmd.Command.InstallProfile.Payload), fmt.Sprintf("<string>%s</string", mobileconfig.FleetFileVaultPayloadIdentifier)) &&
|
||
strings.Contains(string(fullCmd.Command.InstallProfile.Payload), "ForceEnableInSetupAssistant") {
|
||
unexpectedProfileSeen = true
|
||
}
|
||
case "InstallApplication":
|
||
if logCommands {
|
||
fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType, fmt.Sprint(*fullCmd.Command.InstallApplication.ITunesStoreID))
|
||
}
|
||
for _, app := range opts.VppAppsToInstall {
|
||
if app.AdamID == fmt.Sprint(*fullCmd.Command.InstallApplication.ITunesStoreID) {
|
||
installedVPPApps = append(installedVPPApps, fleet.Software{BundleIdentifier: app.BundleIdentifier, Name: app.Name, Version: app.LatestVersion, Installed: true})
|
||
installedApps[app.AdamID]++
|
||
}
|
||
}
|
||
installAppCount++
|
||
|
||
case "InstallEnterpriseApplication":
|
||
if logCommands {
|
||
if fullCmd.Command.InstallEnterpriseApplication.ManifestURL != nil {
|
||
fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType, *fullCmd.Command.InstallEnterpriseApplication.ManifestURL)
|
||
} else {
|
||
fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType)
|
||
}
|
||
}
|
||
case "InstalledApplicationList":
|
||
if logCommands {
|
||
fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType)
|
||
}
|
||
// If we are polling to verify the install, we should get an
|
||
// InstalledApplicationList command instead of an InstallApplication command.
|
||
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
|
||
// Hold off on verifying the last install until later so we can ensure it waits for verification
|
||
if len(installedVPPApps) == len(opts.VppAppsToInstall) {
|
||
installedVPPApps[len(installedVPPApps)-1].Installed = false
|
||
}
|
||
cmd, err = mdmDevice.AcknowledgeInstalledApplicationList(
|
||
mdmDevice.UUID,
|
||
cmd.CommandUUID,
|
||
installedVPPApps,
|
||
)
|
||
// flip the status back for later
|
||
installedVPPApps[len(installedVPPApps)-1].Installed = true
|
||
require.NoError(t, err)
|
||
// TODO: We don't actually normally get a command back from the acknowledgement of the InstalledAppList
|
||
// but we'll get additional install commands if we follow it up with an idle. Is this a bug? I think it
|
||
// may be because of how we handle activating the next upcoming activity?
|
||
if cmd == nil {
|
||
cmd, err = mdmDevice.Idle()
|
||
require.NoError(t, err)
|
||
}
|
||
continue
|
||
default:
|
||
if logCommands {
|
||
fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType)
|
||
}
|
||
otherCount++
|
||
}
|
||
|
||
cmds = append(cmds, &fullCmd)
|
||
|
||
if cmd.Command.RequestType == "InstallApplication" {
|
||
pending, err := s.ds.GetQueuedJobs(ctx, 5, time.Now().UTC().Add(time.Minute))
|
||
require.NoError(t, err)
|
||
for _, job := range pending {
|
||
if job.Name == "apple_software" {
|
||
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
||
_, err := q.ExecContext(ctx, `UPDATE jobs SET not_before = ? WHERE id = ?`, time.Now().Add(-1*time.Minute).UTC(), job.ID)
|
||
return err
|
||
})
|
||
}
|
||
}
|
||
// Run the worker to process the VPP verification job before acking so that the Verify command is waiting for us
|
||
s.runWorker()
|
||
}
|
||
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
|
||
require.NoError(t, err)
|
||
}
|
||
|
||
// expected commands: install CA, install profile (only the custom one),
|
||
// not expected: account configuration, since enrollment_reference not set
|
||
require.Len(t, cmds, 2+len(opts.VppAppsToInstall))
|
||
|
||
require.Equal(t, 2, installProfileCount)
|
||
require.True(t, profileCustomSeen)
|
||
require.True(t, profileFleetCASeen)
|
||
require.Equal(t, false, unexpectedProfileSeen)
|
||
|
||
require.Equal(t, len(opts.VppAppsToInstall), installAppCount)
|
||
require.Equal(t, len(opts.VppAppsToInstall), len(installedApps))
|
||
|
||
// Each expected app should be installed exactly once
|
||
for _, app := range opts.VppAppsToInstall {
|
||
require.Equal(t, 1, installedApps[app.AdamID])
|
||
}
|
||
|
||
require.Equal(t, 0, otherCount)
|
||
|
||
pendingReleaseJobs := []*fleet.Job{}
|
||
if opts.EnableReleaseManually {
|
||
// get the worker's pending job from the future, there should not be any
|
||
// because it needs to be released manually
|
||
pending, err := s.ds.GetQueuedJobs(ctx, 5, time.Now().UTC().Add(time.Minute))
|
||
require.NoError(t, err)
|
||
for _, job := range pending {
|
||
if job.Name == "apple_mdm" && strings.Contains(string(*job.Args), string(worker.AppleMDMPostDEPReleaseDeviceTask)) {
|
||
pendingReleaseJobs = append(pendingReleaseJobs, job)
|
||
} else if job.Name == "apple_software" {
|
||
// Just delete the job for now to keep things clean
|
||
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
||
_, err := q.ExecContext(ctx, `DELETE FROM jobs WHERE id = ?`, job.ID)
|
||
return err
|
||
})
|
||
}
|
||
}
|
||
require.Len(t, pendingReleaseJobs, 0)
|
||
return
|
||
}
|
||
|
||
// Automatic release - device release job should be enqueued
|
||
pending, err := s.ds.GetQueuedJobs(ctx, 5, time.Now().UTC().Add(time.Minute))
|
||
pendingVerifyJobs := []*fleet.Job{}
|
||
require.NoError(t, err)
|
||
for _, job := range pending {
|
||
if job.Name == "apple_mdm" && strings.Contains(string(*job.Args), string(worker.AppleMDMPostDEPReleaseDeviceTask)) {
|
||
pendingReleaseJobs = append(pendingReleaseJobs, job)
|
||
}
|
||
if job.Name == "apple_software" {
|
||
pendingVerifyJobs = append(pendingVerifyJobs, job)
|
||
}
|
||
}
|
||
require.Len(t, pendingReleaseJobs, 1)
|
||
require.Equal(t, "apple_mdm", pendingReleaseJobs[0].Name)
|
||
require.Contains(t, string(*pendingReleaseJobs[0].Args), worker.AppleMDMPostDEPReleaseDeviceTask)
|
||
|
||
require.Len(t, pendingVerifyJobs, 1)
|
||
|
||
// make the pending jobs ready to run immediately and run the job
|
||
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
||
_, err := q.ExecContext(ctx, `UPDATE jobs SET not_before = ? WHERE id IN (?, ?)`, time.Now().Add(-1*time.Minute).UTC(), pendingReleaseJobs[0].ID, pendingVerifyJobs[0].ID)
|
||
return err
|
||
})
|
||
|
||
s.awaitRunAppleMDMWorkerSchedule()
|
||
s.runWorker()
|
||
|
||
// make the device process the commands, it should receive the VPP Verify.
|
||
// It should not receive a DeviceConfigured command!
|
||
cmds = cmds[:0]
|
||
cmd, err = mdmDevice.Idle()
|
||
require.NoError(t, err)
|
||
for cmd != nil {
|
||
var fullCmd micromdm.CommandPayload
|
||
if strings.HasPrefix(cmd.CommandUUID, fleet.RefetchAppsCommandUUID()) {
|
||
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
|
||
require.NoError(t, err)
|
||
continue
|
||
}
|
||
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
|
||
|
||
if cmd.Command.RequestType == "InstalledApplicationList" {
|
||
if logCommands {
|
||
fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType)
|
||
}
|
||
cmd, err = mdmDevice.AcknowledgeInstalledApplicationList(
|
||
mdmDevice.UUID,
|
||
cmd.CommandUUID,
|
||
installedVPPApps,
|
||
)
|
||
require.NoError(t, err)
|
||
// See above comment about cmd==nil, just want to make sure we don't get any additional
|
||
// commands on the acknowledgement
|
||
if cmd == nil {
|
||
cmd, err = mdmDevice.Idle()
|
||
require.NoError(t, err)
|
||
}
|
||
continue
|
||
}
|
||
require.FailNowf(t, "unexpected command", "got command %s of type %s", cmd.CommandUUID, cmd.Command.RequestType)
|
||
}
|
||
|
||
pending, err = s.ds.GetQueuedJobs(ctx, 5, time.Now().UTC().Add(time.Minute))
|
||
pendingReleaseJobs = pendingReleaseJobs[:0]
|
||
pendingVerifyJobs = pendingVerifyJobs[:0]
|
||
require.NoError(t, err)
|
||
for _, job := range pending {
|
||
if job.Name == "apple_mdm" && strings.Contains(string(*job.Args), string(worker.AppleMDMPostDEPReleaseDeviceTask)) {
|
||
pendingReleaseJobs = append(pendingReleaseJobs, job)
|
||
}
|
||
if job.Name == "apple_software" {
|
||
pendingVerifyJobs = append(pendingVerifyJobs, job)
|
||
}
|
||
}
|
||
require.Len(t, pendingReleaseJobs, 1)
|
||
require.Equal(t, "apple_mdm", pendingReleaseJobs[0].Name)
|
||
require.Contains(t, string(*pendingReleaseJobs[0].Args), worker.AppleMDMPostDEPReleaseDeviceTask)
|
||
|
||
require.Len(t, pendingVerifyJobs, 0)
|
||
|
||
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
||
_, err := q.ExecContext(ctx, `UPDATE jobs SET not_before = ? WHERE id = ?`, time.Now().Add(-1*time.Minute).UTC(), pendingReleaseJobs[0].ID)
|
||
return err
|
||
})
|
||
|
||
s.awaitRunAppleMDMWorkerSchedule()
|
||
s.runWorker()
|
||
|
||
// make the device process the commands, it should receive the
|
||
// DeviceConfigured one.
|
||
cmds = cmds[:0]
|
||
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, 1)
|
||
var deviceConfiguredCount int
|
||
for _, cmd := range cmds {
|
||
if strings.HasPrefix(cmd.CommandUUID, fleet.RefetchAppsCommandUUIDPrefix) || strings.HasPrefix(cmd.CommandUUID, fleet.VerifySoftwareInstallVPPPrefix) {
|
||
refetchVerifyCount++
|
||
continue
|
||
}
|
||
switch cmd.Command.RequestType {
|
||
case "DeviceConfigured":
|
||
deviceConfiguredCount++
|
||
default:
|
||
otherCount++
|
||
}
|
||
}
|
||
require.Equal(t, 1, deviceConfiguredCount)
|
||
require.Equal(t, 0, otherCount)
|
||
}
|
||
|
||
// TestSetupExperienceEndpointsPlatformIsolation tests that setting the setup experience software items
|
||
// for one platform doesn't remove the items for another platform on the same team.
|
||
func (s *integrationMDMTestSuite) TestSetupExperienceEndpointsPlatformIsolation() {
|
||
t := s.T()
|
||
ctx := context.Background()
|
||
|
||
team1, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team 1"})
|
||
require.NoError(t, err)
|
||
|
||
// Add a macOS software to the setup experience on team 1.
|
||
payloadDummy := &fleet.UploadSoftwareInstallerPayload{
|
||
InstallScript: "install",
|
||
Filename: "dummy_installer.pkg",
|
||
Title: "DummyApp",
|
||
TeamID: &team1.ID,
|
||
}
|
||
s.uploadSoftwareInstaller(t, payloadDummy, http.StatusOK, "")
|
||
titleID := getSoftwareTitleID(t, s.ds, payloadDummy.Title, "apps")
|
||
var swInstallResp putSetupExperienceSoftwareResponse
|
||
s.DoJSON("PUT", "/api/v1/fleet/setup_experience/software", putSetupExperienceSoftwareRequest{
|
||
Platform: "macos",
|
||
TeamID: team1.ID,
|
||
TitleIDs: []uint{titleID},
|
||
}, http.StatusOK, &swInstallResp)
|
||
|
||
// Clear all Linux software on the setup experience.
|
||
swInstallResp = putSetupExperienceSoftwareResponse{}
|
||
s.DoJSON("PUT", "/api/v1/fleet/setup_experience/software", putSetupExperienceSoftwareRequest{
|
||
Platform: "macos",
|
||
TeamID: team1.ID,
|
||
TitleIDs: []uint{},
|
||
}, http.StatusOK, &swInstallResp)
|
||
|
||
// Get setup experience items for macOS should return the one item.
|
||
var respGetSetupExperience getSetupExperienceSoftwareResponse
|
||
s.DoJSON("GET", "/api/latest/fleet/setup_experience/software", getSetupExperienceSoftwareRequest{},
|
||
http.StatusOK,
|
||
&respGetSetupExperience,
|
||
"platform", "macos",
|
||
"team_id", fmt.Sprint(team1.ID),
|
||
)
|
||
require.Len(t, respGetSetupExperience.SoftwareTitles, 1)
|
||
}
|
||
|
||
func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithRequireSoftware() {
|
||
t := s.T()
|
||
ctx := context.Background()
|
||
s.setSkipWorkerJobs(t)
|
||
|
||
teamDevice, enrolledHost, _ := s.createTeamDeviceForSetupExperienceWithProfileSoftwareAndScript()
|
||
|
||
payload := map[string]any{
|
||
"require_all_software_macos": true,
|
||
"team_id": enrolledHost.TeamID,
|
||
}
|
||
s.Do("PATCH", "/api/latest/fleet/setup_experience", json.RawMessage(jsonMustMarshal(t, payload)), http.StatusNoContent)
|
||
|
||
pk1Title := getSoftwareTitleID(t, s.ds, "DummyApp", "apps")
|
||
// Add another couple of packages
|
||
// Add 2nd package to the team.
|
||
pk2 := &fleet.UploadSoftwareInstallerPayload{
|
||
InstallScript: "install script for pkg2",
|
||
Filename: "no_version.pkg",
|
||
Title: "pkg2",
|
||
TeamID: enrolledHost.TeamID,
|
||
}
|
||
s.uploadSoftwareInstaller(t, pk2, http.StatusOK, "")
|
||
pk2Title := getSoftwareTitleID(t, s.ds, "NoVersion", "apps")
|
||
// Add 3rd package to the team.
|
||
pk3 := &fleet.UploadSoftwareInstallerPayload{
|
||
InstallScript: "install script for pkg3",
|
||
Filename: "EchoApp.pkg",
|
||
Title: "pkg3",
|
||
TeamID: enrolledHost.TeamID,
|
||
}
|
||
s.uploadSoftwareInstaller(t, pk3, http.StatusOK, "")
|
||
pk3Title := getSoftwareTitleID(t, s.ds, "EchoApp", "apps")
|
||
|
||
var swInstallResp putSetupExperienceSoftwareResponse
|
||
s.DoJSON("PUT", "/api/v1/fleet/setup_experience/software", putSetupExperienceSoftwareRequest{TeamID: *enrolledHost.TeamID, TitleIDs: []uint{pk1Title, pk2Title, pk3Title}}, http.StatusOK, &swInstallResp)
|
||
|
||
// enroll the host
|
||
depURLToken := loadEnrollmentProfileDEPToken(t, s.ds)
|
||
mdmDevice := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, depURLToken)
|
||
mdmDevice.SerialNumber = teamDevice.SerialNumber
|
||
err := mdmDevice.Enroll()
|
||
require.NoError(t, err)
|
||
|
||
// ensure fleet profiles
|
||
s.awaitTriggerProfileSchedule(t)
|
||
// run the worker to process the DEP enroll request
|
||
s.awaitRunAppleMDMWorkerSchedule()
|
||
s.runWorker()
|
||
|
||
var cmds []*micromdm.CommandPayload
|
||
cmd, err := mdmDevice.Idle()
|
||
require.NoError(t, err)
|
||
for cmd != nil {
|
||
if cmd.Command.RequestType == "DeclarativeManagement" {
|
||
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
|
||
require.NoError(t, err)
|
||
continue
|
||
}
|
||
|
||
var fullCmd micromdm.CommandPayload
|
||
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
|
||
|
||
// Can be useful for debugging
|
||
// switch cmd.Command.RequestType {
|
||
// case "InstallProfile":
|
||
// fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType, string(fullCmd.Command.InstallProfile.Payload))
|
||
// case "InstallEnterpriseApplication":
|
||
// if fullCmd.Command.InstallEnterpriseApplication.ManifestURL != nil {
|
||
// fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType, *fullCmd.Command.InstallEnterpriseApplication.ManifestURL)
|
||
// } else {
|
||
// fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType)
|
||
// }
|
||
// default:
|
||
// fmt.Println(">>>> device received command: ", cmd.Command.RequestType)
|
||
// }
|
||
|
||
cmds = append(cmds, &fullCmd)
|
||
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
|
||
require.NoError(t, err)
|
||
}
|
||
|
||
// expected commands: install fleetd (install enterprise), install profiles
|
||
// (custom one, fleetd configuration, fleet CA root)
|
||
require.Len(t, cmds, 4)
|
||
var installProfileCount, installEnterpriseCount, otherCount int
|
||
var profileCustomSeen, profileFleetdSeen, profileFleetCASeen, profileFileVaultSeen bool
|
||
for _, cmd := range cmds {
|
||
switch cmd.Command.RequestType {
|
||
case "InstallProfile":
|
||
installProfileCount++
|
||
switch {
|
||
case strings.Contains(string(cmd.Command.InstallProfile.Payload), "<string>I1</string>"):
|
||
profileCustomSeen = true
|
||
case strings.Contains(string(cmd.Command.InstallProfile.Payload), fmt.Sprintf("<string>%s</string>", mobileconfig.FleetdConfigPayloadIdentifier)):
|
||
profileFleetdSeen = true
|
||
case strings.Contains(string(cmd.Command.InstallProfile.Payload), fmt.Sprintf("<string>%s</string>", mobileconfig.FleetCARootConfigPayloadIdentifier)):
|
||
profileFleetCASeen = true
|
||
case strings.Contains(string(cmd.Command.InstallProfile.Payload), fmt.Sprintf("<string>%s</string", mobileconfig.FleetFileVaultPayloadIdentifier)) &&
|
||
strings.Contains(string(cmd.Command.InstallProfile.Payload), "ForceEnableInSetupAssistant"):
|
||
profileFileVaultSeen = true
|
||
}
|
||
|
||
case "InstallEnterpriseApplication":
|
||
installEnterpriseCount++
|
||
default:
|
||
otherCount++
|
||
}
|
||
}
|
||
require.Equal(t, 3, installProfileCount)
|
||
require.Equal(t, 1, installEnterpriseCount)
|
||
require.Equal(t, 0, otherCount)
|
||
require.True(t, profileCustomSeen)
|
||
require.True(t, profileFleetdSeen)
|
||
require.True(t, profileFleetCASeen)
|
||
require.False(t, profileFileVaultSeen)
|
||
|
||
// simulate fleetd being installed and the host being orbit-enrolled now
|
||
enrolledHost.OsqueryHostID = ptr.String(mdmDevice.UUID)
|
||
enrolledHost.UUID = mdmDevice.UUID
|
||
orbitKey := setOrbitEnrollment(t, enrolledHost, s.ds)
|
||
enrolledHost.OrbitNodeKey = &orbitKey
|
||
|
||
// there shouldn't be a worker Release Device pending job (we don't release that way anymore)
|
||
pending, err := s.ds.GetQueuedJobs(ctx, 1, time.Now().UTC().Add(time.Minute))
|
||
require.NoError(t, err)
|
||
require.Len(t, pending, 0)
|
||
|
||
// call the /status endpoint, the software and script should be pending.
|
||
// Note that this kicks off the next step of the setup experience, so while
|
||
// the API response will show the software as "pending", they will now be
|
||
// set as "running" in the database.
|
||
var statusResp fleet.GetOrbitSetupExperienceStatusResponse
|
||
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "reset_failed_setup_steps": true}`, *enrolledHost.OrbitNodeKey)), http.StatusOK, &statusResp)
|
||
require.Nil(t, statusResp.Results.BootstrapPackage) // no bootstrap package involved
|
||
require.Nil(t, statusResp.Results.AccountConfiguration) // no SSO involved
|
||
require.Len(t, statusResp.Results.ConfigurationProfiles, 3) // fleetd config, root CA, custom profile
|
||
require.True(t, statusResp.Results.RequireAllSoftware)
|
||
var profNames []string
|
||
var profStatuses []fleet.MDMDeliveryStatus
|
||
for _, prof := range statusResp.Results.ConfigurationProfiles {
|
||
profNames = append(profNames, prof.Name)
|
||
profStatuses = append(profStatuses, prof.Status)
|
||
}
|
||
require.ElementsMatch(t, []string{"N1", "Fleetd configuration", "Fleet root certificate authority (CA)"}, profNames)
|
||
require.ElementsMatch(t, []fleet.MDMDeliveryStatus{fleet.MDMDeliveryVerifying, fleet.MDMDeliveryVerifying, fleet.MDMDeliveryVerifying}, profStatuses)
|
||
|
||
// the software and script are still pending
|
||
require.NotNil(t, statusResp.Results.Script)
|
||
require.Equal(t, "script.sh", statusResp.Results.Script.Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Script.Status)
|
||
require.Len(t, statusResp.Results.Software, 3)
|
||
for _, softwareResult := range statusResp.Results.Software {
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, softwareResult.Status)
|
||
}
|
||
|
||
// The /setup_experience/status endpoint doesn't return the various IDs for executions, so pull
|
||
// them out manually
|
||
results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, enrolledHost.UUID, *enrolledHost.TeamID)
|
||
require.NoError(t, err)
|
||
require.Len(t, results, 4)
|
||
var installUUIDs []string
|
||
for _, r := range results {
|
||
if r.HostSoftwareInstallsExecutionID != nil {
|
||
installUUIDs = append(installUUIDs, *r.HostSoftwareInstallsExecutionID)
|
||
}
|
||
}
|
||
require.Len(t, installUUIDs, 1)
|
||
|
||
// debugPrintActivities := func(activities []*fleet.UpcomingActivity) []string {
|
||
// var res []string
|
||
// for _, activity := range activities {
|
||
// res = append(res, fmt.Sprintf("%+v", activity))
|
||
// }
|
||
// return res
|
||
// }
|
||
|
||
// Check upcoming activities: we should only have the software upcoming because we don't run the
|
||
// script until after the software is done
|
||
var hostActivitiesResp listHostUpcomingActivitiesResponse
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", enrolledHost.ID),
|
||
nil, http.StatusOK, &hostActivitiesResp)
|
||
|
||
// Get the install UUIDs in the activities
|
||
activityInstallUUIDs := make([]string, len(hostActivitiesResp.Activities))
|
||
for i, activity := range hostActivitiesResp.Activities {
|
||
if activity.Details != nil {
|
||
var detail struct {
|
||
InstallUUID string `json:"install_uuid"`
|
||
}
|
||
err := json.Unmarshal(*activity.Details, &detail)
|
||
require.NoError(t, err)
|
||
activityInstallUUIDs[i] = detail.InstallUUID
|
||
}
|
||
}
|
||
// Verify that they match the install IDs in the setup experience.
|
||
for _, installUUID := range installUUIDs {
|
||
require.Contains(t, activityInstallUUIDs, installUUID)
|
||
}
|
||
|
||
// no MDM command got enqueued due to the /status call (device not released yet)
|
||
cmd, err = mdmDevice.Idle()
|
||
require.NoError(t, err)
|
||
require.Nil(t, cmd)
|
||
|
||
// Check status again. Software should all be listed as "running" now.
|
||
// Since no results have been recorded, this shouldn't change any database state.
|
||
statusResp = fleet.GetOrbitSetupExperienceStatusResponse{}
|
||
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *enrolledHost.OrbitNodeKey)), http.StatusOK, &statusResp)
|
||
// First software is now running, other software and script are still pending
|
||
require.Equal(t, len(statusResp.Results.Software), 3)
|
||
for i, softwareResult := range statusResp.Results.Software {
|
||
if i == 0 {
|
||
require.Equal(t, fleet.SetupExperienceStatusRunning, softwareResult.Status)
|
||
continue
|
||
}
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, softwareResult.Status)
|
||
}
|
||
require.NotNil(t, statusResp.Results.Script)
|
||
require.Equal(t, "script.sh", statusResp.Results.Script.Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Script.Status)
|
||
|
||
// record a successful result for the first software installation
|
||
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, installUUIDs[0])), http.StatusNoContent)
|
||
|
||
results, err = s.ds.ListSetupExperienceResultsByHostUUID(ctx, enrolledHost.UUID, *enrolledHost.TeamID)
|
||
require.NoError(t, err)
|
||
require.Len(t, results, 4)
|
||
installUUIDs = []string{}
|
||
for _, r := range results {
|
||
if r.HostSoftwareInstallsExecutionID != nil {
|
||
installUUIDs = append(installUUIDs, *r.HostSoftwareInstallsExecutionID)
|
||
}
|
||
}
|
||
require.Len(t, installUUIDs, 2)
|
||
|
||
// status still shows script as pending
|
||
statusResp = fleet.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) // no bootstrap package involved
|
||
require.Nil(t, statusResp.Results.AccountConfiguration) // no SSO involved
|
||
require.Len(t, statusResp.Results.ConfigurationProfiles, 3) // fleetd config, root CA, custom profile
|
||
require.NotNil(t, statusResp.Results.Script)
|
||
require.Equal(t, "script.sh", statusResp.Results.Script.Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Script.Status)
|
||
require.Len(t, statusResp.Results.Software, 3)
|
||
require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusSuccess, statusResp.Results.Software[0].Status)
|
||
require.Equal(t, fleet.SetupExperienceStatusRunning, statusResp.Results.Software[1].Status)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Software[2].Status)
|
||
|
||
// Record a failure for the second software.
|
||
s.Do("POST", "/api/fleet/orbit/software_install/result",
|
||
json.RawMessage(fmt.Sprintf(`{
|
||
"orbit_node_key": %q,
|
||
"install_uuid": %q,
|
||
"install_script_exit_code": 1,
|
||
"install_script_output": "nope"
|
||
}`, *enrolledHost.OrbitNodeKey, installUUIDs[1])), http.StatusNoContent)
|
||
|
||
// no MDM command got enqueued due to the /status call (device not released yet)
|
||
cmd, err = mdmDevice.Idle()
|
||
require.NoError(t, err)
|
||
require.Nil(t, cmd)
|
||
|
||
// Get the setup experience status again.
|
||
statusResp = fleet.GetOrbitSetupExperienceStatusResponse{}
|
||
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *enrolledHost.OrbitNodeKey)), http.StatusOK, &statusResp)
|
||
// Script should be marked as failed since required software install failed.
|
||
require.NotNil(t, statusResp.Results.Script)
|
||
require.Equal(t, "script.sh", statusResp.Results.Script.Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusFailure, statusResp.Results.Script.Status)
|
||
require.Len(t, statusResp.Results.Software, 3)
|
||
// The successful install should remain successful.
|
||
require.Equal(t, fleet.SetupExperienceStatusSuccess, statusResp.Results.Software[0].Status)
|
||
// The software we recorded as failed should have the failed state.
|
||
require.Equal(t, fleet.SetupExperienceStatusFailure, statusResp.Results.Software[1].Status)
|
||
// The software we were waiting to install should have the failed state
|
||
// because required software failed.
|
||
require.Equal(t, fleet.SetupExperienceStatusFailure, statusResp.Results.Software[2].Status)
|
||
|
||
// There should be no upcoming activities.
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", enrolledHost.ID),
|
||
nil, http.StatusOK, &hostActivitiesResp)
|
||
require.Equal(t, len(hostActivitiesResp.Activities), 0)
|
||
|
||
// Reset the setup experience items.
|
||
statusResp = fleet.GetOrbitSetupExperienceStatusResponse{}
|
||
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "reset_failed_setup_steps": true}`, *enrolledHost.OrbitNodeKey)), http.StatusOK, &statusResp)
|
||
// The script should be back to "pending"
|
||
require.NotNil(t, statusResp.Results.Script)
|
||
require.Equal(t, "script.sh", statusResp.Results.Script.Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Script.Status)
|
||
require.Len(t, statusResp.Results.Software, 3)
|
||
// The successful install should remain successful.
|
||
require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusSuccess, statusResp.Results.Software[0].Status)
|
||
// Other two software should go back to "pending"
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Software[1].Status)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Software[2].Status)
|
||
}
|
||
|
||
func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithRequiredSoftwareVPP() {
|
||
t := s.T()
|
||
ctx := context.Background()
|
||
|
||
s.setSkipWorkerJobs(t)
|
||
|
||
teamDevice, enrolledHost, team := s.createTeamDeviceForSetupExperienceWithProfileSoftwareAndScript()
|
||
|
||
payload := map[string]any{
|
||
"require_all_software_macos": true,
|
||
"team_id": enrolledHost.TeamID,
|
||
}
|
||
s.Do("PATCH", "/api/latest/fleet/setup_experience", json.RawMessage(jsonMustMarshal(t, payload)), http.StatusNoContent)
|
||
|
||
orgName := "Fleet Device Management Inc."
|
||
token := "mycooltoken"
|
||
expTime := time.Now().Add(200 * time.Hour).UTC().Round(time.Second)
|
||
expDate := expTime.Format(fleet.VPPTimeFormat)
|
||
tokenJSON := fmt.Sprintf(`{"expDate":"%s","token":"%s","orgName":"%s"}`, expDate, token, orgName)
|
||
dev_mode.SetOverride("FLEET_DEV_VPP_URL", s.appleVPPConfigSrv.URL, t)
|
||
var validToken uploadVPPTokenResponse
|
||
s.uploadDataViaForm("/api/latest/fleet/vpp_tokens", "token", "token.vpptoken", []byte(base64.StdEncoding.EncodeToString([]byte(tokenJSON))), http.StatusAccepted, "", &validToken)
|
||
|
||
var getVPPTokenResp getVPPTokensResponse
|
||
s.DoJSON("GET", "/api/latest/fleet/vpp_tokens", &getVPPTokensRequest{}, http.StatusOK, &getVPPTokenResp)
|
||
|
||
// Add an app with 1 license available
|
||
s.appleVPPConfigSrvConfig.Assets = append(s.appleVPPConfigSrvConfig.Assets, vpp.Asset{
|
||
AdamID: "5",
|
||
PricingParam: "STDQ",
|
||
AvailableCount: 1,
|
||
})
|
||
|
||
// Add an app with 0 licenses available
|
||
s.appleVPPConfigSrvConfig.Assets = append(s.appleVPPConfigSrvConfig.Assets, vpp.Asset{
|
||
AdamID: "4",
|
||
PricingParam: "STDQ",
|
||
AvailableCount: 0,
|
||
})
|
||
|
||
t.Cleanup(func() {
|
||
s.appleVPPConfigSrvConfig.Assets = defaultVPPAssetList
|
||
})
|
||
|
||
// Associate team to the VPP token.
|
||
var resPatchVPP patchVPPTokensTeamsResponse
|
||
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", getVPPTokenResp.Tokens[0].ID), patchVPPTokensTeamsRequest{TeamIDs: []uint{team.ID}}, http.StatusOK, &resPatchVPP)
|
||
|
||
// Add the app with 0 licenses available
|
||
s.Do("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{TeamID: &team.ID, AppStoreID: "5", SelfService: true}, http.StatusOK)
|
||
// Add the app with 1 licenses available
|
||
s.Do("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{TeamID: &team.ID, AppStoreID: "4", SelfService: true}, http.StatusOK)
|
||
|
||
// Set custom display names on the VPP apps so they sort alphabetically
|
||
// after DummyApp, with the no-license app sorting first among the VPP
|
||
// apps. This ensures the installer (DummyApp) installs first, then the
|
||
// VPP app with no license fails immediately, triggering the
|
||
// "require all software" cancel-on-failure flow after a successful
|
||
// installer install.
|
||
// Alphabetical order by display name: DummyApp < VPP AAA No License < VPP ZZZ Has License
|
||
//
|
||
// This also exercises the fix for #41741: setup experience ordering
|
||
// should use the custom display name when set.
|
||
vppTitleID := getSoftwareTitleID(t, s.ds, "App 5", "apps")
|
||
vppTitleID2 := getSoftwareTitleID(t, s.ds, "App 4", "apps")
|
||
|
||
var updateAppResp updateAppStoreAppResponse
|
||
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", vppTitleID),
|
||
&updateAppStoreAppRequest{TeamID: &team.ID, DisplayName: ptr.String("VPP ZZZ Has License")},
|
||
http.StatusOK, &updateAppResp)
|
||
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", vppTitleID2),
|
||
&updateAppStoreAppRequest{TeamID: &team.ID, DisplayName: ptr.String("VPP AAA No License")},
|
||
http.StatusOK, &updateAppResp)
|
||
|
||
// Add the VPP apps to setup experience
|
||
installerTitleID := getSoftwareTitleID(t, s.ds, "DummyApp", "apps")
|
||
var swInstallResp putSetupExperienceSoftwareResponse
|
||
s.DoJSON("PUT", "/api/v1/fleet/setup_experience/software", putSetupExperienceSoftwareRequest{TeamID: team.ID, TitleIDs: []uint{vppTitleID, vppTitleID2, installerTitleID}}, http.StatusOK, &swInstallResp)
|
||
|
||
// enroll the host
|
||
depURLToken := loadEnrollmentProfileDEPToken(t, s.ds)
|
||
mdmDevice := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, depURLToken)
|
||
mdmDevice.SerialNumber = teamDevice.SerialNumber
|
||
err := mdmDevice.Enroll()
|
||
require.NoError(t, err)
|
||
|
||
// ensure fleet profiles
|
||
s.awaitTriggerProfileSchedule(t)
|
||
// run the worker to process the DEP enroll request
|
||
s.awaitRunAppleMDMWorkerSchedule()
|
||
s.runWorker()
|
||
|
||
var cmds []*micromdm.CommandPayload
|
||
cmd, err := mdmDevice.Idle()
|
||
require.NoError(t, err)
|
||
for cmd != nil {
|
||
if cmd.Command.RequestType == "DeclarativeManagement" {
|
||
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
|
||
require.NoError(t, err)
|
||
continue
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
// expected commands: install fleetd (install enterprise), install profiles
|
||
// (custom one, fleetd configuration, fleet CA root)
|
||
require.Len(t, cmds, 4)
|
||
var installProfileCount, installEnterpriseCount, otherCount int
|
||
var profileCustomSeen, profileFleetdSeen, profileFleetCASeen, profileFileVaultSeen bool
|
||
for _, cmd := range cmds {
|
||
switch cmd.Command.RequestType {
|
||
case "InstallProfile":
|
||
installProfileCount++
|
||
switch {
|
||
case strings.Contains(string(cmd.Command.InstallProfile.Payload), "<string>I1</string>"):
|
||
profileCustomSeen = true
|
||
case strings.Contains(string(cmd.Command.InstallProfile.Payload), fmt.Sprintf("<string>%s</string>", mobileconfig.FleetdConfigPayloadIdentifier)):
|
||
profileFleetdSeen = true
|
||
case strings.Contains(string(cmd.Command.InstallProfile.Payload), fmt.Sprintf("<string>%s</string>", mobileconfig.FleetCARootConfigPayloadIdentifier)):
|
||
profileFleetCASeen = true
|
||
case strings.Contains(string(cmd.Command.InstallProfile.Payload), fmt.Sprintf("<string>%s</string", mobileconfig.FleetFileVaultPayloadIdentifier)) &&
|
||
strings.Contains(string(cmd.Command.InstallProfile.Payload), "ForceEnableInSetupAssistant"):
|
||
profileFileVaultSeen = true
|
||
}
|
||
|
||
case "InstallEnterpriseApplication":
|
||
installEnterpriseCount++
|
||
default:
|
||
otherCount++
|
||
}
|
||
}
|
||
require.Equal(t, 3, installProfileCount)
|
||
require.Equal(t, 1, installEnterpriseCount)
|
||
require.Equal(t, 0, otherCount)
|
||
require.True(t, profileCustomSeen)
|
||
require.True(t, profileFleetdSeen)
|
||
require.True(t, profileFleetCASeen)
|
||
require.False(t, profileFileVaultSeen)
|
||
|
||
// simulate fleetd being installed and the host being orbit-enrolled now
|
||
enrolledHost.OsqueryHostID = ptr.String(mdmDevice.UUID)
|
||
enrolledHost.UUID = mdmDevice.UUID
|
||
orbitKey := setOrbitEnrollment(t, enrolledHost, s.ds)
|
||
enrolledHost.OrbitNodeKey = &orbitKey
|
||
|
||
// there shouldn't be a worker Release Device pending job (we don't release that way anymore)
|
||
pending, err := s.ds.GetQueuedJobs(ctx, 1, time.Now().UTC().Add(time.Minute))
|
||
require.NoError(t, err)
|
||
require.Len(t, pending, 0)
|
||
|
||
// call the /status endpoint, the software and script should be pending
|
||
var statusResp fleet.GetOrbitSetupExperienceStatusResponse
|
||
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "reset_failed_setup_steps": true}`, *enrolledHost.OrbitNodeKey)), http.StatusOK, &statusResp)
|
||
require.Nil(t, statusResp.Results.BootstrapPackage) // no bootstrap package involved
|
||
require.Nil(t, statusResp.Results.AccountConfiguration) // no SSO involved
|
||
require.Len(t, statusResp.Results.ConfigurationProfiles, 3) // fleetd config, root CA, custom profile
|
||
var profNames []string
|
||
var profStatuses []fleet.MDMDeliveryStatus
|
||
for _, prof := range statusResp.Results.ConfigurationProfiles {
|
||
profNames = append(profNames, prof.Name)
|
||
profStatuses = append(profStatuses, prof.Status)
|
||
}
|
||
require.ElementsMatch(t, []string{"N1", "Fleetd configuration", "Fleet root certificate authority (CA)"}, profNames)
|
||
require.ElementsMatch(t, []fleet.MDMDeliveryStatus{fleet.MDMDeliveryVerifying, fleet.MDMDeliveryVerifying, fleet.MDMDeliveryVerifying}, profStatuses)
|
||
|
||
// the software and script are still pending
|
||
require.NotNil(t, statusResp.Results.Script)
|
||
require.Equal(t, "script.sh", statusResp.Results.Script.Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Script.Status)
|
||
require.Len(t, statusResp.Results.Software, 3)
|
||
// Ordered by display name: DummyApp (no display name), App 4 (display name
|
||
// "VPP AAA No License"), App 5 (display name "VPP ZZZ Has License").
|
||
// Note: Name stores the original st.name, ordering uses display name.
|
||
require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Software[0].Status)
|
||
require.NotNil(t, statusResp.Results.Software[0].SoftwareTitleID)
|
||
require.NotZero(t, *statusResp.Results.Software[0].SoftwareTitleID)
|
||
require.Equal(t, "App 4", statusResp.Results.Software[1].Name)
|
||
require.Equal(t, "VPP AAA No License", statusResp.Results.Software[1].DisplayName)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Software[1].Status)
|
||
require.Equal(t, "App 5", statusResp.Results.Software[2].Name)
|
||
require.Equal(t, "VPP ZZZ Has License", statusResp.Results.Software[2].DisplayName)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Software[2].Status)
|
||
|
||
// call /status endpoint again, DummyApp (first by display name) should be running
|
||
statusResp = fleet.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, 3)
|
||
require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name, "DummyApp should be first by display name")
|
||
require.Equal(t, fleet.SetupExperienceStatusRunning, statusResp.Results.Software[0].Status)
|
||
|
||
// The /setup_experience/status endpoint doesn't return the various IDs for executions, so pull
|
||
// it out manually
|
||
results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, enrolledHost.UUID, team.ID)
|
||
require.NoError(t, err)
|
||
require.Len(t, results, 4)
|
||
var installUUID string
|
||
for _, r := range results {
|
||
if r.HostSoftwareInstallsExecutionID != nil &&
|
||
r.SoftwareInstallerID != nil &&
|
||
r.Name == statusResp.Results.Software[0].Name {
|
||
installUUID = *r.HostSoftwareInstallsExecutionID
|
||
break
|
||
}
|
||
}
|
||
|
||
require.NotEmpty(t, installUUID)
|
||
|
||
// Need to get the software title to get the package name
|
||
var getSoftwareTitleResp getSoftwareTitleResponse
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", *statusResp.Results.Software[0].SoftwareTitleID), nil, http.StatusOK, &getSoftwareTitleResp, "team_id", fmt.Sprintf("%d", *enrolledHost.TeamID))
|
||
require.NotNil(t, getSoftwareTitleResp.SoftwareTitle)
|
||
require.NotNil(t, getSoftwareTitleResp.SoftwareTitle.SoftwarePackage)
|
||
|
||
// record a result for software installation
|
||
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)
|
||
|
||
statusResp = fleet.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) // no bootstrap package involved
|
||
require.Nil(t, statusResp.Results.AccountConfiguration) // no SSO involved
|
||
require.Len(t, statusResp.Results.ConfigurationProfiles, 3) // fleetd config, root CA, custom profile
|
||
require.NotNil(t, statusResp.Results.Script)
|
||
require.Equal(t, "script.sh", statusResp.Results.Script.Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusFailure, statusResp.Results.Script.Status)
|
||
require.Len(t, statusResp.Results.Software, 3)
|
||
require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusSuccess, statusResp.Results.Software[0].Status)
|
||
// App 4 (display name "VPP AAA No License") has no licenses available, so
|
||
// it should fail immediately. Because we have "require_all_software_macos"
|
||
// set, the remaining software (App 5) and the script should be marked as
|
||
// failed too.
|
||
require.Equal(t, "App 4", statusResp.Results.Software[1].Name)
|
||
require.Equal(t, "VPP AAA No License", statusResp.Results.Software[1].DisplayName)
|
||
require.Equal(t, fleet.SetupExperienceStatusFailure, statusResp.Results.Software[1].Status)
|
||
require.Equal(t, "App 5", statusResp.Results.Software[2].Name)
|
||
require.Equal(t, "VPP ZZZ Has License", statusResp.Results.Software[2].DisplayName)
|
||
require.Equal(t, fleet.SetupExperienceStatusFailure, statusResp.Results.Software[2].Status)
|
||
|
||
// Reset the setup experience items.
|
||
statusResp = fleet.GetOrbitSetupExperienceStatusResponse{}
|
||
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "reset_failed_setup_steps": true}`, *enrolledHost.OrbitNodeKey)), http.StatusOK, &statusResp)
|
||
// the software and script are still pending
|
||
require.NotNil(t, statusResp.Results.Script)
|
||
require.Equal(t, "script.sh", statusResp.Results.Script.Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Script.Status)
|
||
require.Len(t, statusResp.Results.Software, 3)
|
||
require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusSuccess, statusResp.Results.Software[0].Status)
|
||
require.NotNil(t, statusResp.Results.Software[0].SoftwareTitleID)
|
||
require.NotZero(t, *statusResp.Results.Software[0].SoftwareTitleID)
|
||
require.Equal(t, "App 4", statusResp.Results.Software[1].Name)
|
||
require.Equal(t, "VPP AAA No License", statusResp.Results.Software[1].DisplayName)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Software[1].Status)
|
||
require.Equal(t, "App 5", statusResp.Results.Software[2].Name)
|
||
require.Equal(t, "VPP ZZZ Has License", statusResp.Results.Software[2].DisplayName)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Software[2].Status)
|
||
}
|
||
|
||
func (s *integrationMDMTestSuite) TestSetupExperienceGetPutSoftware() {
|
||
t := s.T()
|
||
s.setSkipWorkerJobs(t)
|
||
|
||
s.setVPPTokenForTeam(0)
|
||
|
||
// add a macOS software installer
|
||
payloadDummy := &fleet.UploadSoftwareInstallerPayload{
|
||
InstallScript: "install",
|
||
Filename: "dummy_installer.pkg",
|
||
Title: "DummyApp",
|
||
TeamID: nil,
|
||
}
|
||
s.uploadSoftwareInstaller(t, payloadDummy, http.StatusOK, "")
|
||
|
||
// add a VPP app only available on macos
|
||
s.Do("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{TeamID: nil, Platform: "darwin", AppStoreID: "1", SelfService: true}, http.StatusOK)
|
||
// add a VPP app available on all macos and ios
|
||
s.Do("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{TeamID: nil, Platform: "darwin", AppStoreID: "2", SelfService: false}, http.StatusOK)
|
||
s.Do("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{TeamID: nil, Platform: "ios", AppStoreID: "2", SelfService: false}, http.StatusOK)
|
||
|
||
// add an iDevice ipa installer
|
||
s.uploadSoftwareInstaller(t, &fleet.UploadSoftwareInstallerPayload{Filename: "ipa_test.ipa"}, http.StatusOK, "")
|
||
|
||
// get the macos title IDs
|
||
var listSoftware listSoftwareTitlesResponse
|
||
s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{}, http.StatusOK, &listSoftware, "team_id", "0", "platform", "darwin", "order_key", "name")
|
||
require.Len(t, listSoftware.SoftwareTitles, 3)
|
||
app1MacTitleID := listSoftware.SoftwareTitles[0].ID
|
||
app2MacTitleID := listSoftware.SoftwareTitles[1].ID
|
||
pkgTitleID := listSoftware.SoftwareTitles[2].ID
|
||
|
||
// get the ios title IDs
|
||
listSoftware = listSoftwareTitlesResponse{}
|
||
s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{}, http.StatusOK, &listSoftware, "team_id", "0", "platform", "ios", "order_key", "name")
|
||
require.Len(t, listSoftware.SoftwareTitles, 2)
|
||
app2IOSTitleID := listSoftware.SoftwareTitles[0].ID
|
||
ipaTitleID := listSoftware.SoftwareTitles[1].ID
|
||
|
||
// list software for setup experience macos
|
||
var listSetupSoftware getSetupExperienceSoftwareResponse
|
||
s.DoJSON("GET", "/api/latest/fleet/setup_experience/software", getSetupExperienceSoftwareRequest{},
|
||
http.StatusOK, &listSetupSoftware, "platform", "macos", "team_id", "0", "order_key", "name")
|
||
|
||
require.Len(t, listSetupSoftware.SoftwareTitles, 3)
|
||
require.Equal(t, "App 1", listSetupSoftware.SoftwareTitles[0].Name)
|
||
require.NotNil(t, listSetupSoftware.SoftwareTitles[0].AppStoreApp)
|
||
require.Equal(t, "1", listSetupSoftware.SoftwareTitles[0].AppStoreApp.AppStoreID)
|
||
require.Equal(t, "App 2", listSetupSoftware.SoftwareTitles[1].Name)
|
||
require.NotNil(t, listSetupSoftware.SoftwareTitles[1].AppStoreApp)
|
||
require.Equal(t, "2", listSetupSoftware.SoftwareTitles[1].AppStoreApp.AppStoreID)
|
||
require.Equal(t, "DummyApp", listSetupSoftware.SoftwareTitles[2].Name)
|
||
require.NotNil(t, listSetupSoftware.SoftwareTitles[2].SoftwarePackage)
|
||
require.Equal(t, "dummy_installer.pkg", listSetupSoftware.SoftwareTitles[2].SoftwarePackage.Name)
|
||
|
||
// list software for setup experience ios
|
||
listSetupSoftware = getSetupExperienceSoftwareResponse{}
|
||
s.DoJSON("GET", "/api/latest/fleet/setup_experience/software", getSetupExperienceSoftwareRequest{},
|
||
http.StatusOK, &listSetupSoftware, "platform", "ios", "team_id", "0", "order_key", "name")
|
||
|
||
// only 1 installer, the VPP app, the ipa is filtered out because unsupported
|
||
require.Len(t, listSetupSoftware.SoftwareTitles, 1)
|
||
require.Equal(t, "App 2", listSetupSoftware.SoftwareTitles[0].Name)
|
||
|
||
// put software for setup experience macos with an unknown one
|
||
res := s.Do("PUT", "/api/latest/fleet/setup_experience/software", putSetupExperienceSoftwareRequest{
|
||
Platform: "macos",
|
||
TeamID: 0,
|
||
TitleIDs: []uint{pkgTitleID, 9999},
|
||
}, http.StatusBadRequest)
|
||
errMsg := extractServerErrorText(res.Body)
|
||
require.Contains(t, errMsg, "at least one selected software title does not exist or is not available for setup experience")
|
||
|
||
// put software for setup experience macos with an invalid one (the ipa)
|
||
res = s.Do("PUT", "/api/latest/fleet/setup_experience/software", putSetupExperienceSoftwareRequest{
|
||
Platform: "macos",
|
||
TeamID: 0,
|
||
TitleIDs: []uint{app1MacTitleID, ipaTitleID},
|
||
}, http.StatusBadRequest)
|
||
errMsg = extractServerErrorText(res.Body)
|
||
require.Contains(t, errMsg, "at least one selected software title does not exist or is not available for setup experience")
|
||
|
||
// put software for setup experience macos with valid ones
|
||
var putSetupSoftware putSetupExperienceSoftwareResponse
|
||
s.DoJSON("PUT", "/api/latest/fleet/setup_experience/software", putSetupExperienceSoftwareRequest{
|
||
Platform: "macos",
|
||
TeamID: 0,
|
||
TitleIDs: []uint{app1MacTitleID, app2MacTitleID},
|
||
}, http.StatusOK, &putSetupSoftware)
|
||
|
||
// put software for setup experience ios with an unknown one
|
||
res = s.Do("PUT", "/api/latest/fleet/setup_experience/software", putSetupExperienceSoftwareRequest{
|
||
Platform: "ios",
|
||
TeamID: 0,
|
||
TitleIDs: []uint{app2IOSTitleID, 9999},
|
||
}, http.StatusBadRequest)
|
||
errMsg = extractServerErrorText(res.Body)
|
||
require.Contains(t, errMsg, "at least one selected software title does not exist or is not available for setup experience")
|
||
|
||
// put software for setup experience ios with an invalid one (ipa)
|
||
res = s.Do("PUT", "/api/latest/fleet/setup_experience/software", putSetupExperienceSoftwareRequest{
|
||
Platform: "ios",
|
||
TeamID: 0,
|
||
TitleIDs: []uint{ipaTitleID},
|
||
}, http.StatusBadRequest)
|
||
errMsg = extractServerErrorText(res.Body)
|
||
require.Contains(t, errMsg, "at least one selected software title does not exist or is not available for setup experience")
|
||
|
||
// put software for setup experience ios with valid ones
|
||
s.DoJSON("PUT", "/api/latest/fleet/setup_experience/software", putSetupExperienceSoftwareRequest{
|
||
Platform: "ios",
|
||
TeamID: 0,
|
||
TitleIDs: []uint{app2IOSTitleID},
|
||
}, http.StatusOK, &putSetupSoftware)
|
||
}
|
||
|
||
func (s *integrationMDMTestSuite) TestSetupExperienceMacOSCustomDisplayNameIcon() {
|
||
t := s.T()
|
||
s.setSkipWorkerJobs(t)
|
||
|
||
device, host, tm := s.createTeamDeviceForSetupExperienceWithProfileSoftwareAndScript()
|
||
token := "token_test_setup"
|
||
createDeviceTokenForHost(t, s.ds, host.ID, token)
|
||
|
||
// get the created setup experience software title id
|
||
var setupExpSw getSetupExperienceSoftwareResponse
|
||
s.DoJSON("GET", "/api/v1/fleet/setup_experience/software", getSetupExperienceSoftwareRequest{Platforms: "macos"}, http.StatusOK, &setupExpSw, "team_id", fmt.Sprint(tm.ID))
|
||
require.Len(t, setupExpSw.SoftwareTitles, 1)
|
||
dummyTitleID := setupExpSw.SoftwareTitles[0].ID
|
||
|
||
// add an additional macOS software to install during setup experience
|
||
installer := &fleet.UploadSoftwareInstallerPayload{
|
||
InstallScript: "install",
|
||
Filename: "EchoApp.pkg",
|
||
Title: "EchoApp",
|
||
TeamID: &tm.ID,
|
||
}
|
||
s.uploadSoftwareInstaller(t, installer, http.StatusOK, "")
|
||
echoTitleID := getSoftwareTitleID(t, s.ds, installer.Title, "apps")
|
||
var swInstallResp putSetupExperienceSoftwareResponse
|
||
s.DoJSON("PUT", "/api/v1/fleet/setup_experience/software", putSetupExperienceSoftwareRequest{TeamID: tm.ID, TitleIDs: []uint{echoTitleID, dummyTitleID}}, http.StatusOK, &swInstallResp)
|
||
|
||
// set a custom icon and custom display name for that app
|
||
s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{
|
||
TitleID: echoTitleID,
|
||
TeamID: &tm.ID,
|
||
DisplayName: ptr.String("My Custom EchoApp"),
|
||
}, http.StatusOK, "")
|
||
|
||
iconBytes, err := os.ReadFile("testdata/icons/valid-icon.png")
|
||
require.NoError(t, err)
|
||
body, headers := generateMultipartRequest(t, "icon", "icon.png", iconBytes, s.token, nil)
|
||
s.DoRawWithHeaders("PUT", fmt.Sprintf("/api/latest/fleet/software/titles/%d/icon?team_id=%d", echoTitleID, tm.ID),
|
||
body.Bytes(), http.StatusOK, headers)
|
||
|
||
// enroll the host
|
||
depURLToken := loadEnrollmentProfileDEPToken(t, s.ds)
|
||
mdmDevice := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, depURLToken)
|
||
mdmDevice.SerialNumber = device.SerialNumber
|
||
err = mdmDevice.Enroll()
|
||
require.NoError(t, err)
|
||
|
||
// ensure fleet profiles
|
||
s.awaitTriggerProfileSchedule(t)
|
||
// run the worker to process the DEP enroll request
|
||
s.awaitRunAppleMDMWorkerSchedule()
|
||
s.runWorker()
|
||
|
||
var cmds []*micromdm.CommandPayload
|
||
cmd, err := mdmDevice.Idle()
|
||
require.NoError(t, err)
|
||
for cmd != nil {
|
||
if cmd.Command.RequestType == "DeclarativeManagement" {
|
||
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
|
||
require.NoError(t, err)
|
||
continue
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
// expected commands: install fleetd (install enterprise), install profiles
|
||
// (custom one, fleetd configuration, fleet CA root)
|
||
require.Len(t, cmds, 4)
|
||
|
||
// simulate fleetd being installed and the host being orbit-enrolled now
|
||
host.OsqueryHostID = ptr.String(mdmDevice.UUID)
|
||
host.UUID = mdmDevice.UUID
|
||
orbitKey := setOrbitEnrollment(t, host, s.ds)
|
||
host.OrbitNodeKey = &orbitKey
|
||
|
||
// call the /status endpoint, the 2 software and script should be pending
|
||
var statusResp fleet.GetOrbitSetupExperienceStatusResponse
|
||
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *host.OrbitNodeKey)), http.StatusOK, &statusResp)
|
||
require.Nil(t, statusResp.Results.BootstrapPackage) // no bootstrap package involved
|
||
require.Nil(t, statusResp.Results.AccountConfiguration) // no SSO involved
|
||
require.Len(t, statusResp.Results.ConfigurationProfiles, 3) // fleetd config, root CA, custom profile
|
||
|
||
// the 2 software and script are pending
|
||
require.NotNil(t, statusResp.Results.Script)
|
||
require.Equal(t, "script.sh", statusResp.Results.Script.Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Script.Status)
|
||
require.Len(t, statusResp.Results.Software, 2)
|
||
require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name)
|
||
require.Empty(t, statusResp.Results.Software[0].DisplayName)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Software[0].Status)
|
||
require.NotNil(t, statusResp.Results.Software[0].SoftwareTitleID)
|
||
require.Equal(t, dummyTitleID, *statusResp.Results.Software[0].SoftwareTitleID)
|
||
require.Empty(t, statusResp.Results.Software[0].IconURL)
|
||
require.Equal(t, "EchoApp", statusResp.Results.Software[1].Name)
|
||
require.Equal(t, "My Custom EchoApp", statusResp.Results.Software[1].DisplayName)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Software[1].Status)
|
||
require.NotNil(t, statusResp.Results.Software[1].SoftwareTitleID)
|
||
require.Equal(t, echoTitleID, *statusResp.Results.Software[1].SoftwareTitleID)
|
||
require.NotEmpty(t, statusResp.Results.Software[1].IconURL)
|
||
|
||
// since this was the call for the orbit endpoint, and not the device-authenticated
|
||
// one, the URL was not transformed for device-authenticated
|
||
require.NotContains(t, statusResp.Results.Software[1].IconURL, "/device/")
|
||
|
||
// requesting the setup experience status via the device-authenticated endpoint
|
||
// returns the custom icon URL ready to be called via device-auth.
|
||
var deviceResp getDeviceSetupExperienceStatusResponse
|
||
res := s.DoRawNoAuth("POST", "/api/latest/fleet/device/"+token+"/setup_experience/status", json.RawMessage{}, http.StatusOK)
|
||
require.NoError(t, json.NewDecoder(res.Body).Decode(&deviceResp))
|
||
require.NoError(t, res.Body.Close())
|
||
require.NoError(t, deviceResp.Err)
|
||
|
||
// Software is installed one at a time in alphabetical order by display
|
||
// name (falling back to name). The previous call to /status kickstarted
|
||
// the first item; the second remains pending until the first finishes.
|
||
require.Len(t, deviceResp.Results.Scripts, 1)
|
||
require.Equal(t, "script.sh", deviceResp.Results.Scripts[0].Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, deviceResp.Results.Scripts[0].Status)
|
||
require.Len(t, deviceResp.Results.Software, 2)
|
||
require.Equal(t, "DummyApp", deviceResp.Results.Software[0].Name)
|
||
require.Empty(t, deviceResp.Results.Software[0].DisplayName)
|
||
require.Equal(t, fleet.SetupExperienceStatusRunning, deviceResp.Results.Software[0].Status)
|
||
require.NotNil(t, deviceResp.Results.Software[0].SoftwareTitleID)
|
||
require.Equal(t, dummyTitleID, *deviceResp.Results.Software[0].SoftwareTitleID)
|
||
require.Empty(t, deviceResp.Results.Software[0].IconURL)
|
||
require.Equal(t, "EchoApp", deviceResp.Results.Software[1].Name)
|
||
require.Equal(t, "My Custom EchoApp", deviceResp.Results.Software[1].DisplayName)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, deviceResp.Results.Software[1].Status)
|
||
require.NotNil(t, deviceResp.Results.Software[1].SoftwareTitleID)
|
||
require.Equal(t, echoTitleID, *deviceResp.Results.Software[1].SoftwareTitleID)
|
||
require.NotEmpty(t, deviceResp.Results.Software[1].IconURL)
|
||
|
||
require.Contains(t, deviceResp.Results.Software[1].IconURL, "/device/"+token)
|
||
}
|
||
|
||
func (s *integrationMDMTestSuite) TestSetupExperienceAndroid() {
|
||
t := s.T()
|
||
ctx := t.Context()
|
||
s.setSkipWorkerJobs(t)
|
||
|
||
s.setVPPTokenForTeam(0)
|
||
enterpriseID := s.enableAndroidMDM(t)
|
||
|
||
// add a macOS software installer
|
||
payloadDummy := &fleet.UploadSoftwareInstallerPayload{
|
||
InstallScript: "install",
|
||
Filename: "dummy_installer.pkg",
|
||
Title: "DummyApp",
|
||
TeamID: nil,
|
||
}
|
||
s.uploadSoftwareInstaller(t, payloadDummy, http.StatusOK, "")
|
||
|
||
// add a VPP app available on macos and ios
|
||
s.Do("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{TeamID: nil, Platform: "darwin", AppStoreID: "2", SelfService: false}, http.StatusOK)
|
||
s.Do("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{TeamID: nil, Platform: "ios", AppStoreID: "2", SelfService: false}, http.StatusOK)
|
||
|
||
// add 2 android apps
|
||
app1 := &fleet.VPPApp{
|
||
VPPAppTeam: fleet.VPPAppTeam{
|
||
VPPAppID: fleet.VPPAppID{
|
||
AdamID: "com.test1",
|
||
Platform: fleet.AndroidPlatform,
|
||
},
|
||
},
|
||
Name: "Test1",
|
||
BundleIdentifier: "com.test1",
|
||
IconURL: "https://example.com/1",
|
||
}
|
||
app2 := &fleet.VPPApp{
|
||
VPPAppTeam: fleet.VPPAppTeam{
|
||
VPPAppID: fleet.VPPAppID{
|
||
AdamID: "com.test2",
|
||
Platform: fleet.AndroidPlatform,
|
||
},
|
||
},
|
||
Name: "Test2",
|
||
BundleIdentifier: "com.test2",
|
||
IconURL: "https://example.com/2",
|
||
}
|
||
|
||
androidApps := []*fleet.VPPApp{app1, app2}
|
||
s.androidAPIClient.EnterprisesApplicationsFunc = func(ctx context.Context, enterpriseName string, packageName string) (*androidmanagement.Application, error) {
|
||
for _, app := range androidApps {
|
||
if app.AdamID == packageName {
|
||
return &androidmanagement.Application{IconUrl: app.IconURL, Title: app.Name}, nil
|
||
}
|
||
}
|
||
return nil, ¬FoundError{}
|
||
}
|
||
|
||
// should be called three times:
|
||
// 1. Fleet agent added during enrollment (via ensureHostSpecificPolicyIsApplied)
|
||
// 2. The 2 Android apps made available for self-install
|
||
// 3. Setup experience with only the app to install at setup (PREINSTALLED)
|
||
var patchAppsCallCount int // no need for mutex, protected via runWorkerUntilDone
|
||
s.androidAPIClient.EnterprisesPoliciesModifyPolicyApplicationsFunc = func(ctx context.Context, policyName string, appPolicies []*androidmanagement.ApplicationPolicy) (*androidmanagement.Policy, error) {
|
||
patchAppsCallCount++
|
||
switch patchAppsCallCount {
|
||
case 1:
|
||
// first call adds Fleet agent during enrollment
|
||
require.Len(t, appPolicies, 1, "first call should add the Fleet agent")
|
||
require.Equal(t, "FORCE_INSTALLED", appPolicies[0].InstallType)
|
||
require.Equal(t, "com.fleetdm.agent", appPolicies[0].PackageName)
|
||
case 2:
|
||
// second call makes apps available for self-install
|
||
require.Len(t, appPolicies, 2, "second call to make apps available for self-install should have 2 apps")
|
||
require.Equal(t, "AVAILABLE", appPolicies[0].InstallType)
|
||
require.Equal(t, app1.VPPAppID.AdamID, appPolicies[0].PackageName)
|
||
require.Equal(t, "AVAILABLE", appPolicies[1].InstallType)
|
||
require.Equal(t, app2.VPPAppID.AdamID, appPolicies[1].PackageName)
|
||
case 3:
|
||
// third call for setup experience, should have only app1 with PREINSTALLED
|
||
require.Len(t, appPolicies, 1, "third call for setup experience should have only 1 app")
|
||
require.Equal(t, "PREINSTALLED", appPolicies[0].InstallType)
|
||
require.Equal(t, app1.VPPAppID.AdamID, appPolicies[0].PackageName)
|
||
default:
|
||
t.Fatalf("unexpected call count %d to EnterprisesPoliciesModifyPolicyApplications", patchAppsCallCount)
|
||
}
|
||
|
||
return &androidmanagement.Policy{Version: int64(patchAppsCallCount)}, nil
|
||
}
|
||
|
||
// add Android app 1
|
||
var addAppResp addAppStoreAppResponse
|
||
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
|
||
AppStoreID: app1.AdamID,
|
||
Platform: fleet.AndroidPlatform,
|
||
}, http.StatusOK, &addAppResp)
|
||
app1TitleID := addAppResp.TitleID
|
||
|
||
// add Android app 2
|
||
addAppResp = addAppStoreAppResponse{}
|
||
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
|
||
AppStoreID: app2.AdamID,
|
||
Platform: fleet.AndroidPlatform,
|
||
}, http.StatusOK, &addAppResp)
|
||
app2TitleID := addAppResp.TitleID
|
||
|
||
require.NotEqual(t, app1TitleID, app2TitleID)
|
||
|
||
// add app 1 to Android setup experience
|
||
var putResp putSetupExperienceSoftwareResponse
|
||
s.DoJSON("PUT", "/api/latest/fleet/setup_experience/software", &putSetupExperienceSoftwareRequest{
|
||
Platform: string(fleet.AndroidPlatform),
|
||
TeamID: 0,
|
||
TitleIDs: []uint{app1TitleID},
|
||
}, http.StatusOK, &putResp)
|
||
|
||
// Run worker to flush out no-op "make_android_app_available" tasks from adding the apps above
|
||
s.runWorkerUntilDoneWithChecks(true)
|
||
|
||
// list the available setup experience software and verify that only app 1 is installed at setup
|
||
var getResp getSetupExperienceSoftwareResponse
|
||
s.DoJSON("GET", "/api/latest/fleet/setup_experience/software", nil, http.StatusOK, &getResp,
|
||
"team_id", "0", "platform", string(fleet.AndroidPlatform), "order_key", "name")
|
||
require.Len(t, getResp.SoftwareTitles, 2)
|
||
require.Equal(t, app1TitleID, getResp.SoftwareTitles[0].ID)
|
||
require.Equal(t, app1.Name, getResp.SoftwareTitles[0].Name)
|
||
require.Equal(t, app1.AdamID, getResp.SoftwareTitles[0].AppStoreApp.AppStoreID)
|
||
require.NotNil(t, getResp.SoftwareTitles[0].AppStoreApp.InstallDuringSetup)
|
||
require.True(t, *getResp.SoftwareTitles[0].AppStoreApp.InstallDuringSetup)
|
||
require.Equal(t, app2TitleID, getResp.SoftwareTitles[1].ID)
|
||
require.Equal(t, app2.Name, getResp.SoftwareTitles[1].Name)
|
||
require.Equal(t, app2.AdamID, getResp.SoftwareTitles[1].AppStoreApp.AppStoreID)
|
||
require.NotNil(t, getResp.SoftwareTitles[1].AppStoreApp.InstallDuringSetup)
|
||
require.False(t, *getResp.SoftwareTitles[1].AppStoreApp.InstallDuringSetup)
|
||
|
||
host, deviceInfo, pubSubToken := s.createAndEnrollAndroidDevice(t, "test-android", nil, false)
|
||
|
||
// Google AMAPI hasn't been hit yet
|
||
require.False(t, s.androidAPIClient.EnterprisesPoliciesModifyPolicyApplicationsFuncInvoked)
|
||
// run worker, should run the job that assigns the app to the host's MDM policy
|
||
s.runWorkerUntilDoneWithChecks(true)
|
||
// should have hit the android API endpoint
|
||
require.True(t, s.androidAPIClient.EnterprisesPoliciesModifyPolicyApplicationsFuncInvoked)
|
||
|
||
var count int
|
||
mysql.ExecAdhocSQL(t, s.ds, func(tx sqlx.ExtContext) error {
|
||
return sqlx.GetContext(ctx, tx, &count,
|
||
`SELECT COUNT(*) FROM android_policy_requests WHERE policy_id = ?`,
|
||
host.UUID)
|
||
})
|
||
// 1. The default enrollment policy
|
||
// 2. The Fleet-enforced per-device policy
|
||
// 3. The patch applications to make apps available for self-service
|
||
// 4. The patch applications to force install at setup experience
|
||
// (Note: Fleet agent install call is not recorded in the database)
|
||
require.Equal(t, 4, count)
|
||
|
||
// the pending install should show up in the host software
|
||
getHostSw := getHostSoftwareResponse{}
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw, "available_for_install", "true",
|
||
"order_key", "name")
|
||
require.Len(t, getHostSw.Software, 2)
|
||
require.NotNil(t, getHostSw.Software[0].AppStoreApp)
|
||
require.Equal(t, app1.AdamID, getHostSw.Software[0].AppStoreApp.AppStoreID)
|
||
require.NotNil(t, getHostSw.Software[0].Status)
|
||
require.Equal(t, fleet.SoftwareInstallPending, *getHostSw.Software[0].Status)
|
||
app1CmdUUID := getHostSw.Software[0].AppStoreApp.LastInstall.CommandUUID
|
||
require.NotNil(t, getHostSw.Software[1].AppStoreApp)
|
||
require.Equal(t, app2.AdamID, getHostSw.Software[1].AppStoreApp.AppStoreID)
|
||
require.Nil(t, getHostSw.Software[1].Status)
|
||
|
||
// send a pub-sub with the software installed, to make it verified
|
||
policyName := fmt.Sprintf("enterprises/%s/policies/%s", enterpriseID, host.UUID)
|
||
reportMsg := statusReportMessageWithEnterpriseSpecificID(
|
||
t,
|
||
androidmanagement.Device{
|
||
Name: deviceInfo.Name,
|
||
EnrollmentTokenData: deviceInfo.EnrollmentTokenData,
|
||
AppliedPolicyName: policyName,
|
||
AppliedPolicyVersion: 3, // policy version 3 is after Fleet agent (1), self-service apps (2), and setup experience (3)
|
||
ApplicationReports: []*androidmanagement.ApplicationReport{
|
||
{PackageName: app1.AdamID, State: "INSTALLED"},
|
||
},
|
||
LastPolicySyncTime: time.Now().Format(time.RFC3339Nano),
|
||
},
|
||
host.UUID,
|
||
)
|
||
req := android_service.PubSubPushRequest{PubSubMessage: *reportMsg}
|
||
s.Do("POST", "/api/v1/fleet/android_enterprise/pubsub", &req, http.StatusOK, "token", string(pubSubToken.Value))
|
||
s.lastActivityOfTypeMatches(fleet.ActivityInstalledAppStoreApp{}.ActivityName(), fmt.Sprintf(`{"app_store_id":%q,
|
||
"command_uuid":%q, "host_display_name":%q, "host_id":%d, "host_platform":%q, "policy_id":null, "policy_name":null, "self_service":false, "from_auto_update": false, "software_title":%q,
|
||
"status":%q}`, app1.AdamID, app1CmdUUID, host.DisplayName(), host.ID, host.Platform, app1.Name, fleet.SoftwareInstalled), 0)
|
||
|
||
// the pending install should now be verified
|
||
getHostSw = getHostSoftwareResponse{}
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw, "available_for_install", "true",
|
||
"order_key", "name")
|
||
require.Len(t, getHostSw.Software, 2)
|
||
require.NotNil(t, getHostSw.Software[0].AppStoreApp)
|
||
require.Equal(t, app1.AdamID, getHostSw.Software[0].AppStoreApp.AppStoreID)
|
||
require.NotNil(t, getHostSw.Software[0].Status)
|
||
require.Equal(t, fleet.SoftwareInstalled, *getHostSw.Software[0].Status)
|
||
require.NotNil(t, getHostSw.Software[1].AppStoreApp)
|
||
require.Equal(t, app2.AdamID, getHostSw.Software[1].AppStoreApp.AppStoreID)
|
||
require.Nil(t, getHostSw.Software[1].Status)
|
||
|
||
// the software now shows up when including available-for-install (tracked via installer, not osquery inventory)
|
||
getHostSw = getHostSoftwareResponse{}
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw, "order_key", "name",
|
||
"include_available_for_install", "true")
|
||
require.Len(t, getHostSw.Software, 2)
|
||
require.NotNil(t, getHostSw.Software[0].AppStoreApp)
|
||
require.Equal(t, app1.AdamID, getHostSw.Software[0].AppStoreApp.AppStoreID)
|
||
require.NotNil(t, getHostSw.Software[0].Status)
|
||
require.Equal(t, fleet.SoftwareInstalled, *getHostSw.Software[0].Status)
|
||
require.NotNil(t, getHostSw.Software[1].AppStoreApp)
|
||
require.Equal(t, app2.AdamID, getHostSw.Software[1].AppStoreApp.AppStoreID)
|
||
require.Nil(t, getHostSw.Software[1].Status)
|
||
|
||
// add app 2 to Android setup experience
|
||
putResp = putSetupExperienceSoftwareResponse{}
|
||
s.DoJSON("PUT", "/api/latest/fleet/setup_experience/software", &putSetupExperienceSoftwareRequest{
|
||
Platform: string(fleet.AndroidPlatform),
|
||
TeamID: 0,
|
||
TitleIDs: []uint{app1TitleID, app2TitleID},
|
||
}, http.StatusOK, &putResp)
|
||
|
||
// enroll another Android device to test 2 apps that install on enroll
|
||
host2, _, _ := s.createAndEnrollAndroidDevice(t, "test-android-2", nil, false)
|
||
|
||
patchAppsCallCount = 0
|
||
s.androidAPIClient.EnterprisesPoliciesModifyPolicyApplicationsFunc = func(ctx context.Context, policyName string, appPolicies []*androidmanagement.ApplicationPolicy) (*androidmanagement.Policy, error) {
|
||
patchAppsCallCount++
|
||
switch patchAppsCallCount {
|
||
case 1:
|
||
// first call adds Fleet agent during enrollment
|
||
require.Len(t, appPolicies, 1, "first call should add the Fleet agent")
|
||
require.Equal(t, "FORCE_INSTALLED", appPolicies[0].InstallType)
|
||
require.Equal(t, "com.fleetdm.agent", appPolicies[0].PackageName)
|
||
case 2:
|
||
// second call to make apps available for self-install
|
||
require.Len(t, appPolicies, 2, "second call to make apps available for self-install should have 2 apps")
|
||
require.Equal(t, "AVAILABLE", appPolicies[0].InstallType)
|
||
require.Equal(t, app1.VPPAppID.AdamID, appPolicies[0].PackageName)
|
||
require.Equal(t, "AVAILABLE", appPolicies[1].InstallType)
|
||
require.Equal(t, app2.VPPAppID.AdamID, appPolicies[1].PackageName)
|
||
case 3:
|
||
// third call for setup experience, should have both apps
|
||
require.Len(t, appPolicies, 2, "third call for setup experience should have 2 apps")
|
||
require.Equal(t, "PREINSTALLED", appPolicies[0].InstallType)
|
||
require.Equal(t, app1.VPPAppID.AdamID, appPolicies[0].PackageName)
|
||
require.Equal(t, "PREINSTALLED", appPolicies[1].InstallType)
|
||
require.Equal(t, app2.VPPAppID.AdamID, appPolicies[1].PackageName)
|
||
default:
|
||
t.Fatalf("unexpected call count %d to EnterprisesPoliciesModifyPolicyApplications", patchAppsCallCount)
|
||
}
|
||
|
||
return &androidmanagement.Policy{Version: int64(patchAppsCallCount)}, nil
|
||
}
|
||
s.runWorkerUntilDoneWithChecks(true)
|
||
|
||
// the pending installs should show up in the host software
|
||
getHostSw = getHostSoftwareResponse{}
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host2.ID), nil, http.StatusOK, &getHostSw, "available_for_install", "true",
|
||
"order_key", "name")
|
||
require.Len(t, getHostSw.Software, 2)
|
||
require.NotNil(t, getHostSw.Software[0].AppStoreApp)
|
||
require.Equal(t, app1.AdamID, getHostSw.Software[0].AppStoreApp.AppStoreID)
|
||
require.NotNil(t, getHostSw.Software[0].Status)
|
||
require.Equal(t, fleet.SoftwareInstallPending, *getHostSw.Software[0].Status)
|
||
require.NotNil(t, getHostSw.Software[1].AppStoreApp)
|
||
require.Equal(t, app2.AdamID, getHostSw.Software[1].AppStoreApp.AppStoreID)
|
||
require.NotNil(t, getHostSw.Software[1].Status)
|
||
require.Equal(t, fleet.SoftwareInstallPending, *getHostSw.Software[1].Status)
|
||
}
|
||
|
||
func (s *integrationMDMTestSuite) TestSetupExperienceAndroidCancelOnUnenroll() {
|
||
t := s.T()
|
||
ctx := t.Context()
|
||
s.setSkipWorkerJobs(t)
|
||
enterpriseID := s.enableAndroidMDM(t)
|
||
|
||
// add 2 android apps
|
||
app1 := &fleet.VPPApp{
|
||
VPPAppTeam: fleet.VPPAppTeam{
|
||
VPPAppID: fleet.VPPAppID{
|
||
AdamID: "com.test1",
|
||
Platform: fleet.AndroidPlatform,
|
||
},
|
||
},
|
||
Name: "Test1",
|
||
BundleIdentifier: "com.test1",
|
||
IconURL: "https://example.com/1",
|
||
}
|
||
app2 := &fleet.VPPApp{
|
||
VPPAppTeam: fleet.VPPAppTeam{
|
||
VPPAppID: fleet.VPPAppID{
|
||
AdamID: "com.test2",
|
||
Platform: fleet.AndroidPlatform,
|
||
},
|
||
},
|
||
Name: "Test2",
|
||
BundleIdentifier: "com.test2",
|
||
IconURL: "https://example.com/2",
|
||
}
|
||
|
||
androidApps := []*fleet.VPPApp{app1, app2}
|
||
s.androidAPIClient.EnterprisesApplicationsFunc = func(ctx context.Context, enterpriseName string, packageName string) (*androidmanagement.Application, error) {
|
||
for _, app := range androidApps {
|
||
if app.AdamID == packageName {
|
||
return &androidmanagement.Application{IconUrl: app.IconURL, Title: app.Name}, nil
|
||
}
|
||
}
|
||
return nil, ¬FoundError{}
|
||
}
|
||
s.androidAPIClient.EnterprisesDevicesDeleteFunc = func(ctx context.Context, deviceName string) error {
|
||
return nil
|
||
}
|
||
|
||
var patchAppsCallCount int // no need for mutex, protected via runWorkerUntilDone
|
||
s.androidAPIClient.EnterprisesPoliciesModifyPolicyApplicationsFunc = func(ctx context.Context, policyName string, appPolicies []*androidmanagement.ApplicationPolicy) (*androidmanagement.Policy, error) {
|
||
patchAppsCallCount++
|
||
return &androidmanagement.Policy{Version: int64(patchAppsCallCount)}, nil
|
||
}
|
||
|
||
// add Android app 1
|
||
var addAppResp addAppStoreAppResponse
|
||
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
|
||
AppStoreID: app1.AdamID,
|
||
Platform: fleet.AndroidPlatform,
|
||
}, http.StatusOK, &addAppResp)
|
||
app1TitleID := addAppResp.TitleID
|
||
|
||
// add Android app 2
|
||
addAppResp = addAppStoreAppResponse{}
|
||
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
|
||
AppStoreID: app2.AdamID,
|
||
Platform: fleet.AndroidPlatform,
|
||
}, http.StatusOK, &addAppResp)
|
||
app2TitleID := addAppResp.TitleID
|
||
|
||
require.NotEqual(t, app1TitleID, app2TitleID)
|
||
|
||
// add app 1 to Android setup experience
|
||
var putResp putSetupExperienceSoftwareResponse
|
||
s.DoJSON("PUT", "/api/latest/fleet/setup_experience/software", &putSetupExperienceSoftwareRequest{
|
||
Platform: string(fleet.AndroidPlatform),
|
||
TeamID: 0,
|
||
TitleIDs: []uint{app1TitleID},
|
||
}, http.StatusOK, &putResp)
|
||
|
||
// enroll a few Android devices, will get app1 at setup
|
||
host1, deviceInfo1, pubSubToken := s.createAndEnrollAndroidDevice(t, "test-1", nil, true)
|
||
host2, _, _ := s.createAndEnrollAndroidDevice(t, "test-2", nil, false)
|
||
host3, _, _ := s.createAndEnrollAndroidDevice(t, "test-3", nil, false)
|
||
|
||
require.False(t, s.androidAPIClient.EnterprisesPoliciesModifyPolicyApplicationsFuncInvoked)
|
||
s.runWorkerUntilDoneWithChecks(true)
|
||
require.True(t, s.androidAPIClient.EnterprisesPoliciesModifyPolicyApplicationsFuncInvoked)
|
||
|
||
// app install is pending
|
||
getHostSw := getHostSoftwareResponse{}
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host1.ID), nil, http.StatusOK, &getHostSw, "available_for_install", "true",
|
||
"query", app1.Name, "order_key", "name")
|
||
require.Len(t, getHostSw.Software, 1)
|
||
require.NotNil(t, getHostSw.Software[0].Status)
|
||
require.EqualValues(t, fleet.SoftwareInstallPending, *getHostSw.Software[0].Status)
|
||
|
||
// turn off MDM for that host, should fail the pending install
|
||
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/mdm", host1.ID), nil, http.StatusNoContent)
|
||
|
||
// app install is still pending as the device hasn't reported back its unenrollment yet
|
||
getHostSw = getHostSoftwareResponse{}
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host1.ID), nil, http.StatusOK, &getHostSw, "available_for_install", "true",
|
||
"query", app1.Name, "order_key", "name")
|
||
require.Len(t, getHostSw.Software, 1)
|
||
require.NotNil(t, getHostSw.Software[0].Status)
|
||
require.EqualValues(t, fleet.SoftwareInstallPending, *getHostSw.Software[0].Status)
|
||
|
||
// send a pub-sub with the status repored as deleted
|
||
policyName := fmt.Sprintf("enterprises/%s/policies/%s", enterpriseID, host1.UUID)
|
||
reportMsg := statusReportMessageWithEnterpriseSpecificID(
|
||
t,
|
||
androidmanagement.Device{
|
||
Name: deviceInfo1.Name,
|
||
EnrollmentTokenData: deviceInfo1.EnrollmentTokenData,
|
||
AppliedPolicyName: policyName,
|
||
AppliedPolicyVersion: 2,
|
||
LastPolicySyncTime: time.Now().Format(time.RFC3339Nano),
|
||
AppliedState: "DELETED",
|
||
},
|
||
host1.UUID,
|
||
)
|
||
req := android_service.PubSubPushRequest{PubSubMessage: *reportMsg}
|
||
s.Do("POST", "/api/v1/fleet/android_enterprise/pubsub", &req, http.StatusOK, "token", string(pubSubToken.Value))
|
||
|
||
// app install is now failed
|
||
getHostSw = getHostSoftwareResponse{}
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host1.ID), nil, http.StatusOK, &getHostSw, "available_for_install", "true",
|
||
"query", app1.Name, "order_key", "name")
|
||
require.Len(t, getHostSw.Software, 1)
|
||
require.NotNil(t, getHostSw.Software[0].Status)
|
||
require.EqualValues(t, fleet.SoftwareInstallFailed, *getHostSw.Software[0].Status)
|
||
app1CmdUUID := getHostSw.Software[0].AppStoreApp.LastInstall.CommandUUID
|
||
|
||
// activities got created as expected
|
||
s.lastActivityOfTypeMatches(fleet.ActivityTypeMDMUnenrolled{}.ActivityName(), fmt.Sprintf(`
|
||
{"enrollment_id": null, "host_display_name": %q, "host_serial": %q, "installed_from_dep": false, "platform": %q}`,
|
||
host1.DisplayName(), "", host1.Platform), 0) // for some reason the serial is force-set to empty string when we create this activity
|
||
s.lastActivityOfTypeMatches(fleet.ActivityInstalledAppStoreApp{}.ActivityName(), fmt.Sprintf(`{"app_store_id":%q,
|
||
"command_uuid":%q, "host_display_name":%q, "host_id":%d, "host_platform":%q, "policy_id":null, "policy_name":null, "self_service":false, "from_auto_update": false, "software_title":%q,
|
||
"status":%q}`, app1.AdamID, app1CmdUUID, host1.DisplayName(), host1.ID, host1.Platform, app1.Name, fleet.SoftwareInstallFailed), 0)
|
||
|
||
// host2 and host3 haven't been unenrolled, app install is still pending
|
||
getHostSw = getHostSoftwareResponse{}
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host2.ID), nil, http.StatusOK, &getHostSw, "available_for_install", "true",
|
||
"query", app1.Name, "order_key", "name")
|
||
require.Len(t, getHostSw.Software, 1)
|
||
require.NotNil(t, getHostSw.Software[0].Status)
|
||
require.EqualValues(t, fleet.SoftwareInstallPending, *getHostSw.Software[0].Status)
|
||
|
||
getHostSw = getHostSoftwareResponse{}
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host3.ID), nil, http.StatusOK, &getHostSw, "available_for_install", "true",
|
||
"query", app1.Name, "order_key", "name")
|
||
require.Len(t, getHostSw.Software, 1)
|
||
require.NotNil(t, getHostSw.Software[0].Status)
|
||
require.EqualValues(t, fleet.SoftwareInstallPending, *getHostSw.Software[0].Status)
|
||
|
||
// turn off MDM for Android globally
|
||
var deleteEnterResp android.DefaultResponse
|
||
s.DoJSON("DELETE", "/api/latest/fleet/android_enterprise", nil, http.StatusOK, &deleteEnterResp)
|
||
|
||
// host2 and host3 app install is now failed, but because Android MDM is now off globally,
|
||
// we can't list software available for install anymore, we have to use adhoc sql to confirm.
|
||
getHostSw = getHostSoftwareResponse{}
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host2.ID), nil, http.StatusOK, &getHostSw, "available_for_install", "true")
|
||
require.Len(t, getHostSw.Software, 0)
|
||
|
||
var countFailed, countOther int
|
||
mysql.ExecAdhocSQL(t, s.ds, func(tx sqlx.ExtContext) error {
|
||
err := sqlx.GetContext(ctx, tx, &countFailed, `SELECT COUNT(*) FROM host_vpp_software_installs WHERE host_id IN (?, ?) AND verification_failed_at IS NOT NULL`,
|
||
host2.ID, host3.ID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
err = sqlx.GetContext(ctx, tx, &countOther, `SELECT COUNT(*) FROM host_vpp_software_installs WHERE host_id IN (?, ?) AND verification_failed_at IS NULL`,
|
||
host2.ID, host3.ID)
|
||
return err
|
||
})
|
||
require.Equal(t, 2, countFailed)
|
||
require.Equal(t, 0, countOther)
|
||
}
|
||
|
||
func (s *integrationMDMTestSuite) TestAndroidAppConfiguration() {
|
||
t := s.T()
|
||
s.setSkipWorkerJobs(t)
|
||
|
||
s.enableAndroidMDM(t)
|
||
|
||
// add some android apps
|
||
app1 := &fleet.VPPApp{
|
||
VPPAppTeam: fleet.VPPAppTeam{
|
||
VPPAppID: fleet.VPPAppID{
|
||
AdamID: "com.test1",
|
||
Platform: fleet.AndroidPlatform,
|
||
},
|
||
},
|
||
Name: "Test1",
|
||
BundleIdentifier: "com.test1",
|
||
IconURL: "https://example.com/1",
|
||
}
|
||
app2 := &fleet.VPPApp{
|
||
VPPAppTeam: fleet.VPPAppTeam{
|
||
VPPAppID: fleet.VPPAppID{
|
||
AdamID: "com.test2",
|
||
Platform: fleet.AndroidPlatform,
|
||
},
|
||
},
|
||
Name: "Test2",
|
||
BundleIdentifier: "com.test2",
|
||
IconURL: "https://example.com/2",
|
||
}
|
||
app3 := &fleet.VPPApp{
|
||
VPPAppTeam: fleet.VPPAppTeam{
|
||
VPPAppID: fleet.VPPAppID{
|
||
AdamID: "com.test3",
|
||
Platform: fleet.AndroidPlatform,
|
||
},
|
||
},
|
||
Name: "Test3",
|
||
BundleIdentifier: "com.test3",
|
||
IconURL: "https://example.com/3",
|
||
}
|
||
|
||
androidApps := []*fleet.VPPApp{app1, app2, app3}
|
||
s.androidAPIClient.EnterprisesApplicationsFunc = func(ctx context.Context, enterpriseName string, packageName string) (*androidmanagement.Application, error) {
|
||
for _, app := range androidApps {
|
||
if app.AdamID == packageName {
|
||
return &androidmanagement.Application{IconUrl: app.IconURL, Title: app.Name}, nil
|
||
}
|
||
}
|
||
return nil, ¬FoundError{}
|
||
}
|
||
|
||
// vars have no need for a mutex, protected via runWorkerUntilDone
|
||
var (
|
||
// records the appPolicies received in the ModifyPolicyApplications calls
|
||
patchAppsPolicies [][]*androidmanagement.ApplicationPolicy
|
||
patchAppsCallCount int
|
||
)
|
||
s.androidAPIClient.EnterprisesPoliciesModifyPolicyApplicationsFunc = func(ctx context.Context, policyName string, appPolicies []*androidmanagement.ApplicationPolicy) (*androidmanagement.Policy, error) {
|
||
patchAppsCallCount++
|
||
patchAppsPolicies = append(patchAppsPolicies, appPolicies)
|
||
|
||
return &androidmanagement.Policy{Version: int64(patchAppsCallCount)}, nil
|
||
}
|
||
|
||
// add Android app 1
|
||
var addAppResp addAppStoreAppResponse
|
||
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
|
||
AppStoreID: app1.AdamID,
|
||
Platform: fleet.AndroidPlatform,
|
||
}, http.StatusOK, &addAppResp)
|
||
app1TitleID := addAppResp.TitleID
|
||
|
||
// add Android app 2
|
||
addAppResp = addAppStoreAppResponse{}
|
||
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
|
||
AppStoreID: app2.AdamID,
|
||
Platform: fleet.AndroidPlatform,
|
||
}, http.StatusOK, &addAppResp)
|
||
app2TitleID := addAppResp.TitleID
|
||
|
||
require.NotEqual(t, app1TitleID, app2TitleID)
|
||
|
||
s.runWorkerUntilDoneWithChecks(true)
|
||
|
||
// worker should have done nothing (no host to add apps to yet)
|
||
require.Len(t, patchAppsPolicies, 0)
|
||
patchAppsPolicies = nil
|
||
|
||
var patchAppResp updateAppStoreAppResponse
|
||
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", app1TitleID), &updateAppStoreAppRequest{
|
||
TeamID: nil,
|
||
Configuration: json.RawMessage(`{"managedConfiguration": 1}`),
|
||
}, http.StatusOK, &patchAppResp)
|
||
|
||
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", app2TitleID), &updateAppStoreAppRequest{
|
||
TeamID: nil,
|
||
Configuration: json.RawMessage(`{"managedConfiguration": 2}`),
|
||
}, http.StatusOK, &patchAppResp)
|
||
|
||
// add app 1 and 2 to Android setup experience
|
||
var putResp putSetupExperienceSoftwareResponse
|
||
s.DoJSON("PUT", "/api/latest/fleet/setup_experience/software", &putSetupExperienceSoftwareRequest{
|
||
Platform: string(fleet.AndroidPlatform),
|
||
TeamID: 0,
|
||
TitleIDs: []uint{app1TitleID, app2TitleID},
|
||
}, http.StatusOK, &putResp)
|
||
|
||
s.createAndEnrollAndroidDevice(t, "test-android", nil, false)
|
||
|
||
s.runWorkerUntilDoneWithChecks(true)
|
||
|
||
// worker should have:
|
||
// 1. made each app available to the included hosts (for self-service), so 2 entries for that (from the PATCH apps to set the config)
|
||
// (this is because I made the worker run after host enrollment, if there were no host, the task would have nothing to do)
|
||
// 2. added the Fleet agent to the host's policy (from the host enrollment, via ensureHostSpecificPolicyIsApplied)
|
||
// 3. made all apps available to the enrolled host (for self-service), from the host enrollment
|
||
// 4. installed the apps, from the host enrollment
|
||
require.Len(t, patchAppsPolicies, 5)
|
||
require.ElementsMatch(t, []*androidmanagement.ApplicationPolicy{
|
||
{PackageName: app1.VPPAppID.AdamID, InstallType: "AVAILABLE", ManagedConfiguration: googleapi.RawMessage(`1`)},
|
||
}, patchAppsPolicies[0])
|
||
require.ElementsMatch(t, []*androidmanagement.ApplicationPolicy{
|
||
{PackageName: app2.VPPAppID.AdamID, InstallType: "AVAILABLE", ManagedConfiguration: googleapi.RawMessage(`2`)},
|
||
}, patchAppsPolicies[1])
|
||
// Fleet agent is added during enrollment before self-service apps
|
||
require.Len(t, patchAppsPolicies[2], 1)
|
||
require.Equal(t, "com.fleetdm.agent", patchAppsPolicies[2][0].PackageName)
|
||
require.Equal(t, "FORCE_INSTALLED", patchAppsPolicies[2][0].InstallType)
|
||
require.ElementsMatch(t, []*androidmanagement.ApplicationPolicy{
|
||
{PackageName: app1.VPPAppID.AdamID, InstallType: "AVAILABLE", ManagedConfiguration: googleapi.RawMessage(`1`)},
|
||
{PackageName: app2.VPPAppID.AdamID, InstallType: "AVAILABLE", ManagedConfiguration: googleapi.RawMessage(`2`)},
|
||
}, patchAppsPolicies[3])
|
||
require.ElementsMatch(t, []*androidmanagement.ApplicationPolicy{
|
||
{PackageName: app1.VPPAppID.AdamID, InstallType: "PREINSTALLED", ManagedConfiguration: googleapi.RawMessage(`1`)},
|
||
{PackageName: app2.VPPAppID.AdamID, InstallType: "PREINSTALLED", ManagedConfiguration: googleapi.RawMessage(`2`)},
|
||
}, patchAppsPolicies[4])
|
||
|
||
patchAppsPolicies = nil
|
||
|
||
// add app3 to Fleet
|
||
addAppResp = addAppStoreAppResponse{}
|
||
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
|
||
AppStoreID: app3.AdamID,
|
||
Platform: fleet.AndroidPlatform,
|
||
}, http.StatusOK, &addAppResp)
|
||
app3TitleID := addAppResp.TitleID
|
||
|
||
s.runWorkerUntilDoneWithChecks(true)
|
||
|
||
// worker should have:
|
||
// 1. made the apps available to the host (for self-service), without any config provided
|
||
require.Len(t, patchAppsPolicies, 1)
|
||
require.ElementsMatch(t, []*androidmanagement.ApplicationPolicy{
|
||
{PackageName: app3.VPPAppID.AdamID, InstallType: "AVAILABLE", ManagedConfiguration: googleapi.RawMessage{}, WorkProfileWidgets: "WORK_PROFILE_WIDGETS_UNSPECIFIED"},
|
||
}, patchAppsPolicies[0])
|
||
|
||
patchAppsPolicies = nil
|
||
|
||
// set a configuration for the app3
|
||
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", app3TitleID), &updateAppStoreAppRequest{
|
||
TeamID: nil,
|
||
Configuration: json.RawMessage(`{"managedConfiguration": 3}`),
|
||
}, http.StatusOK, &patchAppResp)
|
||
|
||
s.runWorkerUntilDoneWithChecks(true)
|
||
|
||
// worker should have:
|
||
// 1. made the app available with its config
|
||
require.Len(t, patchAppsPolicies, 1)
|
||
require.ElementsMatch(t, []*androidmanagement.ApplicationPolicy{
|
||
{PackageName: app3.VPPAppID.AdamID, InstallType: "AVAILABLE", ManagedConfiguration: googleapi.RawMessage(`3`)},
|
||
}, patchAppsPolicies[0])
|
||
|
||
patchAppsPolicies = nil
|
||
|
||
// patch but no change to the configuration for the app3
|
||
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", app3TitleID), &updateAppStoreAppRequest{
|
||
TeamID: nil,
|
||
Configuration: json.RawMessage(`{"managedConfiguration": 3}`),
|
||
}, http.StatusOK, &patchAppResp)
|
||
|
||
s.runWorkerUntilDoneWithChecks(true)
|
||
|
||
require.Len(t, patchAppsPolicies, 0)
|
||
|
||
// patch with a different config just to trigger the worker
|
||
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", app3TitleID), &updateAppStoreAppRequest{
|
||
TeamID: nil,
|
||
Configuration: json.RawMessage(`{}`),
|
||
}, http.StatusOK, &patchAppResp)
|
||
|
||
// delete directly the config from the DB (it seems like to clear the config from
|
||
// the API, an empty object needs to be passed, but that won't clear the config,
|
||
// it just won't change any managedConfig/widgets - to really clear the config
|
||
// from the API, the user would have to send something like:
|
||
// {
|
||
// "managedConfiguration": null,
|
||
// "workProfileWidgets": "WORK_PROFILE_WIDGETS_UNSPECIFIED"
|
||
// }
|
||
//
|
||
// Is that how we want it to work?
|
||
mysql.ExecAdhocSQL(t, s.ds, func(tx sqlx.ExtContext) error {
|
||
_, err := tx.ExecContext(t.Context(), `DELETE FROM android_app_configurations WHERE application_id = ?`, app3.VPPAppID.AdamID)
|
||
return err
|
||
})
|
||
|
||
s.runWorkerUntilDoneWithChecks(true)
|
||
|
||
// worker should have:
|
||
// 1. made the app available with its config cleared
|
||
require.Len(t, patchAppsPolicies, 1)
|
||
require.ElementsMatch(t, []*androidmanagement.ApplicationPolicy{
|
||
{PackageName: app3.VPPAppID.AdamID, InstallType: "AVAILABLE", ManagedConfiguration: googleapi.RawMessage{}, WorkProfileWidgets: "WORK_PROFILE_WIDGETS_UNSPECIFIED"},
|
||
}, patchAppsPolicies[0])
|
||
}
|
||
|
||
func (s *integrationMDMTestSuite) TestSetupExperienceAndroidWithConfiguration() {
|
||
t := s.T()
|
||
ctx := t.Context()
|
||
s.setSkipWorkerJobs(t)
|
||
|
||
enterpriseID := s.enableAndroidMDM(t)
|
||
|
||
// add 2 android apps
|
||
app1 := &fleet.VPPApp{
|
||
VPPAppTeam: fleet.VPPAppTeam{
|
||
VPPAppID: fleet.VPPAppID{
|
||
AdamID: "com.test1",
|
||
Platform: fleet.AndroidPlatform,
|
||
},
|
||
},
|
||
Name: "Test1",
|
||
BundleIdentifier: "com.test1",
|
||
IconURL: "https://example.com/1",
|
||
}
|
||
app2 := &fleet.VPPApp{
|
||
VPPAppTeam: fleet.VPPAppTeam{
|
||
VPPAppID: fleet.VPPAppID{
|
||
AdamID: "com.test2",
|
||
Platform: fleet.AndroidPlatform,
|
||
},
|
||
},
|
||
Name: "Test2",
|
||
BundleIdentifier: "com.test2",
|
||
IconURL: "https://example.com/2",
|
||
}
|
||
|
||
androidApps := []*fleet.VPPApp{app1, app2}
|
||
s.androidAPIClient.EnterprisesApplicationsFunc = func(ctx context.Context, enterpriseName string, packageName string) (*androidmanagement.Application, error) {
|
||
for _, app := range androidApps {
|
||
if app.AdamID == packageName {
|
||
return &androidmanagement.Application{IconUrl: app.IconURL, Title: app.Name}, nil
|
||
}
|
||
}
|
||
return nil, ¬FoundError{}
|
||
}
|
||
|
||
var patchAppsCallCount int // no need for mutex, protected via runWorkerUntilDone
|
||
s.androidAPIClient.EnterprisesPoliciesModifyPolicyApplicationsFunc = func(ctx context.Context, policyName string, appPolicies []*androidmanagement.ApplicationPolicy) (*androidmanagement.Policy, error) {
|
||
patchAppsCallCount++
|
||
return &androidmanagement.Policy{Version: int64(patchAppsCallCount)}, nil
|
||
}
|
||
|
||
// add Android app 1
|
||
var addAppResp addAppStoreAppResponse
|
||
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
|
||
AppStoreID: app1.AdamID,
|
||
Platform: fleet.AndroidPlatform,
|
||
}, http.StatusOK, &addAppResp)
|
||
app1TitleID := addAppResp.TitleID
|
||
|
||
// add Android app 2
|
||
addAppResp = addAppStoreAppResponse{}
|
||
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
|
||
AppStoreID: app2.AdamID,
|
||
Platform: fleet.AndroidPlatform,
|
||
}, http.StatusOK, &addAppResp)
|
||
app2TitleID := addAppResp.TitleID
|
||
|
||
require.NotEqual(t, app1TitleID, app2TitleID)
|
||
|
||
// add app 1 to Android setup experience
|
||
var putResp putSetupExperienceSoftwareResponse
|
||
s.DoJSON("PUT", "/api/latest/fleet/setup_experience/software", &putSetupExperienceSoftwareRequest{
|
||
Platform: string(fleet.AndroidPlatform),
|
||
TeamID: 0,
|
||
TitleIDs: []uint{app1TitleID},
|
||
}, http.StatusOK, &putResp)
|
||
|
||
s.runWorkerUntilDoneWithChecks(true)
|
||
|
||
tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "test team", Secrets: []*fleet.EnrollSecret{{Secret: uuid.NewString()}}})
|
||
require.NoError(t, err)
|
||
|
||
// enroll a couple android devices on no-team and one on a team. Note host1 is company-owned
|
||
host1, deviceInfo1, pubSubToken := s.createAndEnrollAndroidDevice(t, "test-android1", nil, true)
|
||
host2, deviceInfo2, _ := s.createAndEnrollAndroidDevice(t, "test-android2", nil, false)
|
||
host3, _, _ := s.createAndEnrollAndroidDevice(t, "test-android3", &tm.ID, false)
|
||
|
||
s.runWorkerUntilDoneWithChecks(true)
|
||
|
||
// hosts 1 and 2 have app1 pending install
|
||
getHostSw := getHostSoftwareResponse{}
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host1.ID), nil, http.StatusOK, &getHostSw, "available_for_install", "true",
|
||
"order_key", "name")
|
||
require.Len(t, getHostSw.Software, 2)
|
||
require.NotNil(t, getHostSw.Software[0].AppStoreApp)
|
||
require.Equal(t, app1.AdamID, getHostSw.Software[0].AppStoreApp.AppStoreID)
|
||
require.NotNil(t, getHostSw.Software[0].Status)
|
||
require.Equal(t, fleet.SoftwareInstallPending, *getHostSw.Software[0].Status)
|
||
app1Host1CmdUUID := getHostSw.Software[0].AppStoreApp.LastInstall.CommandUUID
|
||
require.NotNil(t, getHostSw.Software[1].AppStoreApp)
|
||
require.Equal(t, app2.AdamID, getHostSw.Software[1].AppStoreApp.AppStoreID)
|
||
require.Nil(t, getHostSw.Software[1].Status)
|
||
require.NotEmpty(t, app1Host1CmdUUID)
|
||
|
||
getHostSw = getHostSoftwareResponse{}
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host2.ID), nil, http.StatusOK, &getHostSw, "available_for_install", "true",
|
||
"order_key", "name")
|
||
require.Len(t, getHostSw.Software, 2)
|
||
require.NotNil(t, getHostSw.Software[0].AppStoreApp)
|
||
require.Equal(t, app1.AdamID, getHostSw.Software[0].AppStoreApp.AppStoreID)
|
||
require.NotNil(t, getHostSw.Software[0].Status)
|
||
require.Equal(t, fleet.SoftwareInstallPending, *getHostSw.Software[0].Status)
|
||
app1Host2CmdUUID := getHostSw.Software[0].AppStoreApp.LastInstall.CommandUUID
|
||
require.NotNil(t, getHostSw.Software[1].AppStoreApp)
|
||
require.Equal(t, app2.AdamID, getHostSw.Software[1].AppStoreApp.AppStoreID)
|
||
require.Nil(t, getHostSw.Software[1].Status)
|
||
require.NotEmpty(t, app1Host2CmdUUID)
|
||
|
||
// host 3 has no apps available to install
|
||
getHostSw = getHostSoftwareResponse{}
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host3.ID), nil, http.StatusOK, &getHostSw, "available_for_install", "true",
|
||
"order_key", "name")
|
||
require.Len(t, getHostSw.Software, 0)
|
||
|
||
// make the software installed on host1, failed on host2
|
||
policyName := fmt.Sprintf("enterprises/%s/policies/%s", enterpriseID, host1.UUID)
|
||
reportMsg := statusReportMessageWithSerialNumber(
|
||
t,
|
||
androidmanagement.Device{
|
||
Name: deviceInfo1.Name,
|
||
EnrollmentTokenData: deviceInfo1.EnrollmentTokenData,
|
||
AppliedPolicyName: policyName,
|
||
AppliedPolicyVersion: 10,
|
||
ApplicationReports: []*androidmanagement.ApplicationReport{
|
||
{PackageName: app1.AdamID, State: "INSTALLED"},
|
||
},
|
||
LastPolicySyncTime: time.Now().Format(time.RFC3339Nano),
|
||
},
|
||
host1.HardwareSerial,
|
||
)
|
||
req := android_service.PubSubPushRequest{PubSubMessage: *reportMsg}
|
||
s.Do("POST", "/api/v1/fleet/android_enterprise/pubsub", &req, http.StatusOK, "token", string(pubSubToken.Value))
|
||
|
||
policyName = fmt.Sprintf("enterprises/%s/policies/%s", enterpriseID, host2.UUID)
|
||
reportMsg = statusReportMessageWithEnterpriseSpecificID(
|
||
t,
|
||
androidmanagement.Device{
|
||
Name: deviceInfo2.Name,
|
||
EnrollmentTokenData: deviceInfo2.EnrollmentTokenData,
|
||
AppliedPolicyName: policyName,
|
||
AppliedPolicyVersion: 20,
|
||
NonComplianceDetails: []*androidmanagement.NonComplianceDetail{
|
||
{PackageName: app1.AdamID, NonComplianceReason: "APP_NOT_INSTALLED"},
|
||
},
|
||
LastPolicySyncTime: time.Now().Format(time.RFC3339Nano),
|
||
},
|
||
host2.UUID,
|
||
)
|
||
req = android_service.PubSubPushRequest{PubSubMessage: *reportMsg}
|
||
s.Do("POST", "/api/v1/fleet/android_enterprise/pubsub", &req, http.StatusOK, "token", string(pubSubToken.Value))
|
||
|
||
// the pending install should now be verified for host1
|
||
getHostSw = getHostSoftwareResponse{}
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host1.ID), nil, http.StatusOK, &getHostSw, "available_for_install", "true",
|
||
"order_key", "name")
|
||
require.Len(t, getHostSw.Software, 2)
|
||
require.NotNil(t, getHostSw.Software[0].AppStoreApp)
|
||
require.Equal(t, app1.AdamID, getHostSw.Software[0].AppStoreApp.AppStoreID)
|
||
require.NotNil(t, getHostSw.Software[0].Status)
|
||
require.Equal(t, fleet.SoftwareInstalled, *getHostSw.Software[0].Status)
|
||
require.NotNil(t, getHostSw.Software[1].AppStoreApp)
|
||
require.Equal(t, app2.AdamID, getHostSw.Software[1].AppStoreApp.AppStoreID)
|
||
require.Nil(t, getHostSw.Software[1].Status)
|
||
|
||
// the pending install should now be failed for host2
|
||
getHostSw = getHostSoftwareResponse{}
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host2.ID), nil, http.StatusOK, &getHostSw, "available_for_install", "true",
|
||
"order_key", "name")
|
||
require.Len(t, getHostSw.Software, 2)
|
||
require.NotNil(t, getHostSw.Software[0].AppStoreApp)
|
||
require.Equal(t, app1.AdamID, getHostSw.Software[0].AppStoreApp.AppStoreID)
|
||
require.NotNil(t, getHostSw.Software[0].Status)
|
||
require.Equal(t, fleet.SoftwareInstallFailed, *getHostSw.Software[0].Status)
|
||
require.NotNil(t, getHostSw.Software[1].AppStoreApp)
|
||
require.Equal(t, app2.AdamID, getHostSw.Software[1].AppStoreApp.AppStoreID)
|
||
require.Nil(t, getHostSw.Software[1].Status)
|
||
|
||
// set a configuration for app1
|
||
var patchAppResp updateAppStoreAppResponse
|
||
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", app1TitleID), &updateAppStoreAppRequest{
|
||
TeamID: nil,
|
||
Configuration: json.RawMessage(`{"managedConfiguration": 1}`),
|
||
}, http.StatusOK, &patchAppResp)
|
||
|
||
s.runWorkerUntilDoneWithChecks(true)
|
||
|
||
// the verified install should now be back to pending for host1
|
||
getHostSw = getHostSoftwareResponse{}
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host1.ID), nil, http.StatusOK, &getHostSw, "available_for_install", "true",
|
||
"order_key", "name")
|
||
require.Len(t, getHostSw.Software, 2)
|
||
require.NotNil(t, getHostSw.Software[0].AppStoreApp)
|
||
require.Equal(t, app1.AdamID, getHostSw.Software[0].AppStoreApp.AppStoreID)
|
||
require.NotNil(t, getHostSw.Software[0].Status)
|
||
require.Equal(t, fleet.SoftwareInstallPending, *getHostSw.Software[0].Status)
|
||
require.NotNil(t, getHostSw.Software[1].AppStoreApp)
|
||
require.Equal(t, app2.AdamID, getHostSw.Software[1].AppStoreApp.AppStoreID)
|
||
require.Nil(t, getHostSw.Software[1].Status)
|
||
|
||
// the failed install is still failed for host2
|
||
getHostSw = getHostSoftwareResponse{}
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host2.ID), nil, http.StatusOK, &getHostSw, "available_for_install", "true",
|
||
"order_key", "name")
|
||
require.Len(t, getHostSw.Software, 2)
|
||
require.NotNil(t, getHostSw.Software[0].AppStoreApp)
|
||
require.Equal(t, app1.AdamID, getHostSw.Software[0].AppStoreApp.AppStoreID)
|
||
require.NotNil(t, getHostSw.Software[0].Status)
|
||
require.Equal(t, fleet.SoftwareInstallFailed, *getHostSw.Software[0].Status)
|
||
require.NotNil(t, getHostSw.Software[1].AppStoreApp)
|
||
require.Equal(t, app2.AdamID, getHostSw.Software[1].AppStoreApp.AppStoreID)
|
||
require.Nil(t, getHostSw.Software[1].Status)
|
||
|
||
// and still no apps for host3
|
||
getHostSw = getHostSoftwareResponse{}
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host3.ID), nil, http.StatusOK, &getHostSw, "available_for_install", "true",
|
||
"order_key", "name")
|
||
require.Len(t, getHostSw.Software, 0)
|
||
|
||
getPolicyVersion := func(hostID uint, appID string) int64 {
|
||
var version int64
|
||
mysql.ExecAdhocSQL(t, s.ds, func(tx sqlx.ExtContext) error {
|
||
return sqlx.GetContext(ctx, tx, &version, `
|
||
SELECT CAST(associated_event_id AS SIGNED) FROM host_vpp_software_installs WHERE host_id = ? AND adam_id = ?`,
|
||
hostID, appID)
|
||
})
|
||
return version
|
||
}
|
||
versionBefore := getPolicyVersion(host1.ID, app1.AdamID)
|
||
|
||
// update the configuration for app1
|
||
patchAppResp = updateAppStoreAppResponse{}
|
||
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", app1TitleID), &updateAppStoreAppRequest{
|
||
TeamID: nil,
|
||
Configuration: json.RawMessage(`{"managedConfiguration": 2}`),
|
||
}, http.StatusOK, &patchAppResp)
|
||
|
||
s.runWorkerUntilDoneWithChecks(true)
|
||
|
||
// install for host1 will still be pending, but will now be verified only when that
|
||
// latest policy version will get reported
|
||
versionAfter := getPolicyVersion(host1.ID, app1.AdamID)
|
||
require.Greater(t, versionAfter, versionBefore)
|
||
|
||
// the pending install is still pending for host1
|
||
getHostSw = getHostSoftwareResponse{}
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host1.ID), nil, http.StatusOK, &getHostSw, "available_for_install", "true",
|
||
"order_key", "name")
|
||
require.Len(t, getHostSw.Software, 2)
|
||
require.NotNil(t, getHostSw.Software[0].AppStoreApp)
|
||
require.Equal(t, app1.AdamID, getHostSw.Software[0].AppStoreApp.AppStoreID)
|
||
require.NotNil(t, getHostSw.Software[0].Status)
|
||
require.Equal(t, fleet.SoftwareInstallPending, *getHostSw.Software[0].Status)
|
||
require.NotNil(t, getHostSw.Software[1].AppStoreApp)
|
||
require.Equal(t, app2.AdamID, getHostSw.Software[1].AppStoreApp.AppStoreID)
|
||
require.Nil(t, getHostSw.Software[1].Status)
|
||
|
||
// reporting it as installed with the previous policy version does not make it verified
|
||
policyName = fmt.Sprintf("enterprises/%s/policies/%s", enterpriseID, host1.UUID)
|
||
reportMsg = statusReportMessageWithSerialNumber(
|
||
t,
|
||
androidmanagement.Device{
|
||
Name: deviceInfo1.Name,
|
||
EnrollmentTokenData: deviceInfo1.EnrollmentTokenData,
|
||
AppliedPolicyName: policyName,
|
||
AppliedPolicyVersion: versionBefore,
|
||
ApplicationReports: []*androidmanagement.ApplicationReport{
|
||
{PackageName: app1.AdamID, State: "INSTALLED"},
|
||
},
|
||
LastPolicySyncTime: time.Now().Format(time.RFC3339Nano),
|
||
},
|
||
host1.HardwareSerial,
|
||
)
|
||
req = android_service.PubSubPushRequest{PubSubMessage: *reportMsg}
|
||
s.Do("POST", "/api/v1/fleet/android_enterprise/pubsub", &req, http.StatusOK, "token", string(pubSubToken.Value))
|
||
|
||
// still pending
|
||
getHostSw = getHostSoftwareResponse{}
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host1.ID), nil, http.StatusOK, &getHostSw, "available_for_install", "true",
|
||
"order_key", "name")
|
||
require.Len(t, getHostSw.Software, 2)
|
||
require.NotNil(t, getHostSw.Software[0].AppStoreApp)
|
||
require.Equal(t, app1.AdamID, getHostSw.Software[0].AppStoreApp.AppStoreID)
|
||
require.NotNil(t, getHostSw.Software[0].Status)
|
||
require.Equal(t, fleet.SoftwareInstallPending, *getHostSw.Software[0].Status)
|
||
require.NotNil(t, getHostSw.Software[1].AppStoreApp)
|
||
require.Equal(t, app2.AdamID, getHostSw.Software[1].AppStoreApp.AppStoreID)
|
||
require.Nil(t, getHostSw.Software[1].Status)
|
||
|
||
// reporting it as installed with the latest policy version makes it verified
|
||
policyName = fmt.Sprintf("enterprises/%s/policies/%s", enterpriseID, host1.UUID)
|
||
reportMsg = statusReportMessageWithSerialNumber(
|
||
t,
|
||
androidmanagement.Device{
|
||
Name: deviceInfo1.Name,
|
||
EnrollmentTokenData: deviceInfo1.EnrollmentTokenData,
|
||
AppliedPolicyName: policyName,
|
||
AppliedPolicyVersion: versionAfter,
|
||
ApplicationReports: []*androidmanagement.ApplicationReport{
|
||
{PackageName: app1.AdamID, State: "INSTALLED"},
|
||
},
|
||
LastPolicySyncTime: time.Now().Format(time.RFC3339Nano),
|
||
},
|
||
host1.HardwareSerial,
|
||
)
|
||
req = android_service.PubSubPushRequest{PubSubMessage: *reportMsg}
|
||
s.Do("POST", "/api/v1/fleet/android_enterprise/pubsub", &req, http.StatusOK, "token", string(pubSubToken.Value))
|
||
|
||
getHostSw = getHostSoftwareResponse{}
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host1.ID), nil, http.StatusOK, &getHostSw, "available_for_install", "true",
|
||
"order_key", "name")
|
||
require.Len(t, getHostSw.Software, 2)
|
||
require.NotNil(t, getHostSw.Software[0].AppStoreApp)
|
||
require.Equal(t, app1.AdamID, getHostSw.Software[0].AppStoreApp.AppStoreID)
|
||
require.NotNil(t, getHostSw.Software[0].Status)
|
||
require.Equal(t, fleet.SoftwareInstalled, *getHostSw.Software[0].Status)
|
||
require.NotNil(t, getHostSw.Software[1].AppStoreApp)
|
||
require.Equal(t, app2.AdamID, getHostSw.Software[1].AppStoreApp.AppStoreID)
|
||
require.Nil(t, getHostSw.Software[1].Status)
|
||
|
||
// update app1 again, but configuration stays the same
|
||
patchAppResp = updateAppStoreAppResponse{}
|
||
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", app1TitleID), &updateAppStoreAppRequest{
|
||
TeamID: nil,
|
||
Configuration: json.RawMessage(`{"managedConfiguration": 2}`),
|
||
}, http.StatusOK, &patchAppResp)
|
||
|
||
s.runWorkerUntilDoneWithChecks(true)
|
||
|
||
// status stays installed
|
||
getHostSw = getHostSoftwareResponse{}
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host1.ID), nil, http.StatusOK, &getHostSw, "available_for_install", "true",
|
||
"order_key", "name")
|
||
require.Len(t, getHostSw.Software, 2)
|
||
require.NotNil(t, getHostSw.Software[0].AppStoreApp)
|
||
require.Equal(t, app1.AdamID, getHostSw.Software[0].AppStoreApp.AppStoreID)
|
||
require.NotNil(t, getHostSw.Software[0].Status)
|
||
require.Equal(t, fleet.SoftwareInstalled, *getHostSw.Software[0].Status)
|
||
require.NotNil(t, getHostSw.Software[1].AppStoreApp)
|
||
require.Equal(t, app2.AdamID, getHostSw.Software[1].AppStoreApp.AppStoreID)
|
||
require.Nil(t, getHostSw.Software[1].Status)
|
||
}
|
||
|
||
func (s *integrationMDMTestSuite) createAndEnrollAndroidDevice(t *testing.T, name string, teamID *uint, companyOwned bool) (host *fleet.Host, deviceInfo androidmanagement.Device, pubSubToken fleet.MDMConfigAsset) {
|
||
ctx := t.Context()
|
||
|
||
// get the required secrets to enroll an Android device
|
||
secrets, err := s.ds.GetEnrollSecrets(ctx, teamID)
|
||
require.NoError(t, err)
|
||
require.Len(t, secrets, 1)
|
||
|
||
assets, err := s.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetAndroidPubSubToken}, nil)
|
||
require.NoError(t, err)
|
||
pubsubToken := assets[fleet.MDMAssetAndroidPubSubToken]
|
||
require.NotEmpty(t, pubsubToken.Value)
|
||
|
||
// enroll an Android device
|
||
deviceID := createAndroidDeviceID(name)
|
||
identifier := strings.ToUpper(uuid.New().String())
|
||
deviceInfo = androidmanagement.Device{
|
||
Name: deviceID,
|
||
EnrollmentTokenData: fmt.Sprintf(`{"EnrollSecret": "%s"}`, secrets[0].Secret),
|
||
}
|
||
var enrollmentMessage *android.PubSubMessage
|
||
if companyOwned {
|
||
enrollmentMessage = enrollmentMessageWithSerialNumber(t, deviceInfo, identifier)
|
||
} else {
|
||
enrollmentMessage = enrollmentMessageWithEnterpriseSpecificID(t, deviceInfo, identifier)
|
||
}
|
||
|
||
req := android_service.PubSubPushRequest{PubSubMessage: *enrollmentMessage}
|
||
s.Do("POST", "/api/v1/fleet/android_enterprise/pubsub", &req, http.StatusOK, "token", string(pubsubToken.Value))
|
||
|
||
var hosts listHostsResponse
|
||
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &hosts, "query", identifier)
|
||
require.Len(t, hosts.Hosts, 1)
|
||
hostResp := hosts.Hosts[0]
|
||
require.EqualValues(t, fleet.AndroidPlatform, hostResp.Host.Platform)
|
||
|
||
if teamID != nil {
|
||
require.NotNil(t, hostResp.Host.TeamID)
|
||
require.EqualValues(t, *teamID, *hostResp.Host.TeamID)
|
||
} else {
|
||
require.Nil(t, hostResp.Host.TeamID)
|
||
}
|
||
|
||
return hostResp.Host, deviceInfo, pubsubToken
|
||
}
|
||
|
||
func (s *integrationMDMTestSuite) TestLinuxSetupExperienceEnqueueSoftwareInstalls() {
|
||
t := s.T()
|
||
ctx := context.Background()
|
||
s.setSkipWorkerJobs(t)
|
||
|
||
// add a macOS software to install
|
||
payloadDummy := &fleet.UploadSoftwareInstallerPayload{
|
||
Filename: "dummy_installer.pkg",
|
||
Title: "DummyApp",
|
||
TeamID: nil,
|
||
}
|
||
s.uploadSoftwareInstaller(t, payloadDummy, http.StatusOK, "")
|
||
macTitleID := getSoftwareTitleID(t, s.ds, payloadDummy.Title, "apps")
|
||
require.NotZero(t, macTitleID)
|
||
|
||
// add a .deb custom package
|
||
payloadRuby := &fleet.UploadSoftwareInstallerPayload{
|
||
Filename: "ruby.deb",
|
||
Title: "ruby",
|
||
Platform: "linux",
|
||
TeamID: nil,
|
||
}
|
||
s.uploadSoftwareInstaller(t, payloadRuby, http.StatusOK, "")
|
||
debTitleID := getSoftwareTitleID(t, s.ds, payloadRuby.Title, "deb_packages")
|
||
require.NotZero(t, debTitleID)
|
||
|
||
// add a .sh script-only package
|
||
payloadSh := &fleet.UploadSoftwareInstallerPayload{
|
||
Filename: "script.sh",
|
||
Title: "script",
|
||
Platform: "linux",
|
||
TeamID: nil,
|
||
}
|
||
s.uploadSoftwareInstaller(t, payloadSh, http.StatusOK, "")
|
||
shTitleID := getSoftwareTitleID(t, s.ds, payloadSh.Title, "sh_packages")
|
||
require.NotZero(t, shTitleID)
|
||
|
||
var putSetupExpResponse putSetupExperienceSoftwareResponse
|
||
s.DoJSON("PUT", "/api/v1/fleet/setup_experience/software", putSetupExperienceSoftwareRequest{
|
||
TeamID: 0, Platform: "linux", TitleIDs: []uint{debTitleID, shTitleID},
|
||
}, http.StatusOK, &putSetupExpResponse)
|
||
s.DoJSON("PUT", "/api/v1/fleet/setup_experience/software", putSetupExperienceSoftwareRequest{
|
||
TeamID: 0, Platform: "macos", TitleIDs: []uint{macTitleID},
|
||
}, http.StatusOK, &putSetupExpResponse)
|
||
|
||
// create an arch linux host (platform_like is empty)
|
||
hostArch := createOrbitEnrolledHost(t, "arch", "host1", s.ds)
|
||
createDeviceTokenForHost(t, s.ds, hostArch.ID, uuid.NewString())
|
||
|
||
// create a ubuntu host (platform_like is "debian")
|
||
hostUbuntu := createOrbitEnrolledHost(t, "ubuntu", "host2", s.ds)
|
||
hostUbuntu.PlatformLike = "debian"
|
||
require.NoError(t, s.ds.UpdateHost(ctx, hostUbuntu))
|
||
createDeviceTokenForHost(t, s.ds, hostUbuntu.ID, uuid.NewString())
|
||
|
||
// trigger setup experience for arch
|
||
var orbitInitResponse fleet.OrbitSetupExperienceInitResponse
|
||
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/init", fleet.OrbitSetupExperienceInitRequest{
|
||
OrbitNodeKey: *hostArch.OrbitNodeKey,
|
||
}, http.StatusOK, &orbitInitResponse)
|
||
require.True(t, orbitInitResponse.Result.Enabled)
|
||
|
||
// get status of the "Setup experience", only the .sh is compatible
|
||
var orbitStatusResponse fleet.GetOrbitSetupExperienceStatusResponse
|
||
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", fleet.GetOrbitSetupExperienceStatusRequest{
|
||
OrbitNodeKey: *hostArch.OrbitNodeKey,
|
||
}, http.StatusOK, &orbitStatusResponse)
|
||
require.Nil(t, orbitStatusResponse.Results.Script)
|
||
require.Nil(t, orbitStatusResponse.Results.BootstrapPackage)
|
||
require.Len(t, orbitStatusResponse.Results.ConfigurationProfiles, 0)
|
||
require.Nil(t, orbitStatusResponse.Results.AccountConfiguration)
|
||
require.False(t, orbitStatusResponse.Results.RequireAllSoftware)
|
||
|
||
require.Len(t, orbitStatusResponse.Results.Software, 1)
|
||
require.Equal(t, payloadSh.Title, orbitStatusResponse.Results.Software[0].Name)
|
||
require.NotNil(t, orbitStatusResponse.Results.Software[0].SoftwareTitleID)
|
||
require.Equal(t, shTitleID, *orbitStatusResponse.Results.Software[0].SoftwareTitleID)
|
||
|
||
// trigger setup experience for ubuntu
|
||
orbitInitResponse = fleet.OrbitSetupExperienceInitResponse{}
|
||
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/init", fleet.OrbitSetupExperienceInitRequest{
|
||
OrbitNodeKey: *hostUbuntu.OrbitNodeKey,
|
||
}, http.StatusOK, &orbitInitResponse)
|
||
require.True(t, orbitInitResponse.Result.Enabled)
|
||
|
||
// get status of the "Setup experience", both the .sh and .deb are enqueued
|
||
orbitStatusResponse = fleet.GetOrbitSetupExperienceStatusResponse{}
|
||
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", fleet.GetOrbitSetupExperienceStatusRequest{
|
||
OrbitNodeKey: *hostUbuntu.OrbitNodeKey,
|
||
}, http.StatusOK, &orbitStatusResponse)
|
||
require.Nil(t, orbitStatusResponse.Results.Script)
|
||
require.Nil(t, orbitStatusResponse.Results.BootstrapPackage)
|
||
require.Len(t, orbitStatusResponse.Results.ConfigurationProfiles, 0)
|
||
require.Nil(t, orbitStatusResponse.Results.AccountConfiguration)
|
||
require.False(t, orbitStatusResponse.Results.RequireAllSoftware)
|
||
|
||
require.Len(t, orbitStatusResponse.Results.Software, 2)
|
||
sort.Slice(orbitStatusResponse.Results.Software, func(i, j int) bool {
|
||
return orbitStatusResponse.Results.Software[i].Name < orbitStatusResponse.Results.Software[j].Name
|
||
})
|
||
require.Equal(t, payloadRuby.Title, orbitStatusResponse.Results.Software[0].Name)
|
||
require.NotNil(t, orbitStatusResponse.Results.Software[0].SoftwareTitleID)
|
||
require.Equal(t, debTitleID, *orbitStatusResponse.Results.Software[0].SoftwareTitleID)
|
||
require.Equal(t, payloadSh.Title, orbitStatusResponse.Results.Software[1].Name)
|
||
require.NotNil(t, orbitStatusResponse.Results.Software[1].SoftwareTitleID)
|
||
require.Equal(t, shTitleID, *orbitStatusResponse.Results.Software[1].SoftwareTitleID)
|
||
}
|