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 #34433 It speeds up the cron, meaning fleetd, bootstrap and now profiles should be sent within 10 seconds of being known to fleet, compared to the previous 1 minute. It's heavily based on my last PR, so the structure and changes are close to identical, with some small differences. **I did not do the redis key part in this PR, as I think that should come in it's own PR, to avoid overlooking logic bugs with that code, and since this one is already quite sized since we're moving core pieces of code around.** # 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. ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Faster macOS onboarding: device profiles are delivered and installed as part of DEP enrollment, shortening initial setup. * Improved profile handling: per-host profile preprocessing, secret detection, and clearer failure marking. * **Improvements** * Consolidated SCEP/NDES error messaging for clearer diagnostics. * Cron/work scheduling tuned to prioritize Apple MDM profile delivery. * **Tests** * Expanded MDM unit and integration tests, including DeclarativeManagement handling. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
5047 lines
217 KiB
Go
5047 lines
217 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"os"
|
||
"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)
|
||
|
||
// run the worker to process the DEP enroll request
|
||
s.runWorker()
|
||
// run the worker to assign configuration profiles
|
||
s.awaitTriggerProfileSchedule(t)
|
||
|
||
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 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)
|
||
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 = 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 = 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 = 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)
|
||
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 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 = 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())
|
||
|
||
s.runWorker()
|
||
s.awaitTriggerProfileSchedule(t)
|
||
|
||
// Drain the initial MDM commands (InstallProfile × 3 + InstallEnterpriseApplication × 1).
|
||
var cmds []*micromdm.CommandPayload
|
||
cmd, err := mdmDevice.Idle()
|
||
require.NoError(t, err)
|
||
for cmd != nil {
|
||
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 getOrbitSetupExperienceStatusResponse
|
||
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status",
|
||
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *enrolledHost.OrbitNodeKey)),
|
||
http.StatusOK, &statusResp)
|
||
require.Nil(t, statusResp.Results.BootstrapPackage)
|
||
require.Nil(t, statusResp.Results.AccountConfiguration)
|
||
require.Len(t, statusResp.Results.ConfigurationProfiles, 3)
|
||
require.Nil(t, statusResp.Results.Script)
|
||
|
||
require.Len(t, statusResp.Results.Software, 1)
|
||
|
||
fmaResult := statusResp.Results.Software[0]
|
||
require.Equal(t, "1Password", fmaResult.Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, fmaResult.Status)
|
||
require.NotNil(t, fmaResult.SoftwareTitleID)
|
||
require.Equal(t, fmaTitleID, *fmaResult.SoftwareTitleID)
|
||
|
||
// Pull the execution ID out of the DB (the status endpoint doesn't surface it).
|
||
results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, enrolledHost.UUID)
|
||
require.NoError(t, err)
|
||
require.Len(t, results, 1)
|
||
require.NotNil(t, results[0].HostSoftwareInstallsExecutionID)
|
||
installUUID := *results[0].HostSoftwareInstallsExecutionID
|
||
require.NotEmpty(t, installUUID)
|
||
|
||
// Retrieve the title so we can read the active package name (should be v1.0).
|
||
var titleDetail getSoftwareTitleResponse
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", fmaTitleID),
|
||
nil, http.StatusOK, &titleDetail,
|
||
"team_id", fmt.Sprint(tm.ID))
|
||
require.NotNil(t, titleDetail.SoftwareTitle)
|
||
require.NotNil(t, titleDetail.SoftwareTitle.SoftwarePackage)
|
||
require.Equal(t, "1.0", titleDetail.SoftwareTitle.SoftwarePackage.Version,
|
||
"the installed version during setup experience must be the rolled-back v1.0")
|
||
|
||
// No MDM command was enqueued just from the /status call (device not released yet).
|
||
cmd, err = mdmDevice.Idle()
|
||
require.NoError(t, err)
|
||
require.Nil(t, cmd)
|
||
|
||
// Second /status call: software transitions to running.
|
||
statusResp = getOrbitSetupExperienceStatusResponse{}
|
||
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status",
|
||
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *enrolledHost.OrbitNodeKey)),
|
||
http.StatusOK, &statusResp)
|
||
require.Len(t, statusResp.Results.Software, 1)
|
||
require.Equal(t, "1Password", statusResp.Results.Software[0].Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusRunning, statusResp.Results.Software[0].Status)
|
||
|
||
// Verify the upcoming activity references the rolled-back v1.0 package.
|
||
var hostActivitiesResp listHostUpcomingActivitiesResponse
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", enrolledHost.ID),
|
||
nil, http.StatusOK, &hostActivitiesResp)
|
||
require.Len(t, hostActivitiesResp.Activities, 1)
|
||
require.NotNil(t, hostActivitiesResp.Activities[0].Details)
|
||
var activityDetails map[string]any
|
||
require.NoError(t, json.Unmarshal(*hostActivitiesResp.Activities[0].Details, &activityDetails))
|
||
require.Equal(t, installUUID, activityDetails["install_uuid"])
|
||
require.Equal(t, "1Password", activityDetails["software_title"])
|
||
// The package name must come from the v1.0 installer, not v2.0.
|
||
require.Equal(t, titleDetail.SoftwareTitle.SoftwarePackage.Name, activityDetails["software_package"])
|
||
|
||
// Post a successful install result for the FMA.
|
||
s.Do("POST", "/api/fleet/orbit/software_install/result",
|
||
json.RawMessage(fmt.Sprintf(`{
|
||
"orbit_node_key": %q,
|
||
"install_uuid": %q,
|
||
"install_script_exit_code": 0,
|
||
"install_script_output": "ok"
|
||
}`, *enrolledHost.OrbitNodeKey, installUUID)), http.StatusNoContent)
|
||
|
||
// /status call after success: software is now complete.
|
||
statusResp = getOrbitSetupExperienceStatusResponse{}
|
||
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status",
|
||
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *enrolledHost.OrbitNodeKey)),
|
||
http.StatusOK, &statusResp)
|
||
require.Nil(t, statusResp.Results.BootstrapPackage)
|
||
require.Nil(t, statusResp.Results.AccountConfiguration)
|
||
require.Nil(t, statusResp.Results.Script)
|
||
require.Len(t, statusResp.Results.Software, 1)
|
||
require.Equal(t, "1Password", statusResp.Results.Software[0].Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusSuccess, statusResp.Results.Software[0].Status)
|
||
|
||
// The device should now receive a DeviceConfigured command (auto-release).
|
||
cmd, err = mdmDevice.Idle()
|
||
require.NoError(t, err)
|
||
cmds = cmds[:0]
|
||
for cmd != nil {
|
||
var fullCmd micromdm.CommandPayload
|
||
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
|
||
cmds = append(cmds, &fullCmd)
|
||
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
|
||
require.NoError(t, err)
|
||
}
|
||
require.Len(t, cmds, 1)
|
||
require.Equal(t, "DeviceConfigured", cmds[0].Command.RequestType)
|
||
|
||
// Verify the installed-software activity references the rolled-back v1.0 package.
|
||
var getHostResp getHostResponse
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", enrolledHost.ID), nil, http.StatusOK, &getHostResp)
|
||
|
||
expectedActivityDetail := fmt.Sprintf(`
|
||
{
|
||
"host_id": %d,
|
||
"host_display_name": "%s",
|
||
"software_title": "1Password",
|
||
"software_package": "%s",
|
||
"self_service": false,
|
||
"install_uuid": "%s",
|
||
"status": "installed",
|
||
"source": "apps",
|
||
"policy_id": null,
|
||
"policy_name": null
|
||
}
|
||
`, enrolledHost.ID, getHostResp.Host.DisplayName, titleDetail.SoftwareTitle.SoftwarePackage.Name, installUUID)
|
||
s.lastActivityMatchesExtended(fleet.ActivityTypeInstalledSoftware{}.ActivityName(), expectedActivityDetail, 0, ptr.Bool(true))
|
||
}
|
||
|
||
func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithSoftwareAndScriptForceRelease() {
|
||
t := s.T()
|
||
ctx := context.Background()
|
||
|
||
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)
|
||
|
||
// run the worker to process the DEP enroll request
|
||
s.runWorker()
|
||
// run the worker to assign configuration profiles
|
||
s.awaitTriggerProfileSchedule(t)
|
||
|
||
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 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)
|
||
|
||
// run the worker to process the DEP enroll request
|
||
s.runWorker()
|
||
// run the worker to assign configuration profiles
|
||
s.awaitTriggerProfileSchedule(t)
|
||
|
||
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 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, 2)
|
||
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 5", statusResp.Results.Software[1].Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Software[1].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)
|
||
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[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 = 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, 2)
|
||
require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusSuccess, statusResp.Results.Software[0].Status)
|
||
// App 5 has no licenses available, so we should get a status failed here and setup experience
|
||
// should continue
|
||
require.Equal(t, "App 5", statusResp.Results.Software[1].Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusFailure, statusResp.Results.Software[1].Status)
|
||
|
||
// Software installations are done, now we should run the script
|
||
statusResp = getOrbitSetupExperienceStatusResponse{}
|
||
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *enrolledHost.OrbitNodeKey)), http.StatusOK, &statusResp)
|
||
require.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)
|
||
require.Equal(t, "App 5", statusResp.Results.Software[1].Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusFailure, statusResp.Results.Software[1].Status)
|
||
|
||
// Get script exec ID
|
||
results, err = s.ds.ListSetupExperienceResultsByHostUUID(ctx, enrolledHost.UUID)
|
||
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 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 = 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.Equal(t, "App 5", statusResp.Results.Software[1].Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusFailure, 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 process the DEP enroll request
|
||
s.runWorker()
|
||
// run the worker to assign configuration profiles
|
||
s.awaitTriggerProfileSchedule(t)
|
||
|
||
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 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)
|
||
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 = 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 = 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 = 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)
|
||
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 = 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)
|
||
|
||
// run the worker to process the DEP enroll request
|
||
s.runWorker()
|
||
// run the worker to assign configuration profiles
|
||
s.awaitTriggerProfileSchedule(t)
|
||
|
||
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 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)
|
||
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)
|
||
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}
|
||
|
||
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 process the DEP enroll request
|
||
s.runWorker()
|
||
// run the worker to assign configuration profiles
|
||
s.awaitTriggerProfileSchedule(t)
|
||
|
||
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 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.SetupExperienceStatusRunning, software.Status)
|
||
}
|
||
require.NotNil(t, software.SoftwareTitleID)
|
||
require.NotZero(t, *software.SoftwareTitleID)
|
||
}
|
||
|
||
// All apps should have an install record 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, 6, count)
|
||
return nil
|
||
})
|
||
|
||
installedApps := map[string]struct{}{
|
||
macOSApp1.Name: {},
|
||
}
|
||
|
||
for _, app := range expectedApps {
|
||
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.Equal(t, fleet.SetupExperienceStatusSuccess, software.Status, software.Name, software.Status)
|
||
} else {
|
||
require.Equal(t, fleet.SetupExperienceStatusRunning, software.Status)
|
||
}
|
||
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 worker to process the DEP enroll request
|
||
s.runWorker()
|
||
|
||
// run the cron to assign configuration profiles
|
||
s.awaitTriggerProfileSchedule(t)
|
||
|
||
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 := false
|
||
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.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.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)
|
||
|
||
// run the worker to process the DEP enroll request
|
||
s.runWorker()
|
||
// run the worker to assign configuration profiles
|
||
s.awaitTriggerProfileSchedule(t)
|
||
|
||
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 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)
|
||
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.Equal(t, len(installUUIDs), 3)
|
||
|
||
// 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 = 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, len(statusResp.Results.Software), 3)
|
||
for _, softwareResult := range statusResp.Results.Software {
|
||
require.Equal(t, fleet.SetupExperienceStatusRunning, 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)
|
||
|
||
// status still shows script as pending
|
||
statusResp = getOrbitSetupExperienceStatusResponse{}
|
||
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *enrolledHost.OrbitNodeKey)), http.StatusOK, &statusResp)
|
||
require.Nil(t, statusResp.Results.BootstrapPackage) // 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)
|
||
// Other two software should still be "running"
|
||
require.Equal(t, fleet.SetupExperienceStatusRunning, statusResp.Results.Software[1].Status)
|
||
require.Equal(t, fleet.SetupExperienceStatusRunning, 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 = 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 = 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)
|
||
|
||
// Add the VPP app to setup experience
|
||
vppTitleID := getSoftwareTitleID(t, s.ds, "App 5", "apps")
|
||
vppTitleID2 := getSoftwareTitleID(t, s.ds, "App 4", "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, 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)
|
||
|
||
// run the worker to process the DEP enroll request
|
||
s.runWorker()
|
||
// run the worker to assign configuration profiles
|
||
s.awaitTriggerProfileSchedule(t)
|
||
|
||
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 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)
|
||
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, fleet.SetupExperienceStatusPending, statusResp.Results.Software[1].Status)
|
||
require.Equal(t, "App 5", statusResp.Results.Software[2].Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Software[2].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)
|
||
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 = 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 has no licenses available, so it should fail and because we have "requre_all_software_macos" set,
|
||
// the other software and the script should be marked as failed too.
|
||
require.Equal(t, "App 4", statusResp.Results.Software[1].Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusFailure, statusResp.Results.Software[1].Status)
|
||
require.Equal(t, "App 5", statusResp.Results.Software[2].Name)
|
||
require.Equal(t, fleet.SetupExperienceStatusFailure, statusResp.Results.Software[2].Status)
|
||
|
||
// Reset the setup experience items.
|
||
statusResp = 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, fleet.SetupExperienceStatusPending, statusResp.Results.Software[1].Status)
|
||
require.Equal(t, "App 5", statusResp.Results.Software[2].Name)
|
||
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)
|
||
|
||
// run the worker to process the DEP enroll request
|
||
s.runWorker()
|
||
// run the worker to assign configuration profiles
|
||
s.awaitTriggerProfileSchedule(t)
|
||
|
||
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 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)
|
||
|
||
// the software is now running (because previous call to /status kickstarted the process), and script is pending
|
||
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.SetupExperienceStatusRunning, 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 in the host inventory
|
||
getHostSw = getHostSoftwareResponse{}
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw, "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)
|
||
|
||
// 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 orbitSetupExperienceInitResponse
|
||
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/init", 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 getOrbitSetupExperienceStatusResponse
|
||
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", 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 = orbitSetupExperienceInitResponse{}
|
||
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/init", 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 = getOrbitSetupExperienceStatusResponse{}
|
||
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", 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)
|
||
}
|