fleet/server/service/integration_mdm_dep_test.go
Magnus Jensen 57566301e1
Wait for expected profiles to be sent before releasing device (#31381)
This PR addresses the concern of potentially being able to release a
device before any profile is sent, and the check thinking there is no
pending. It addresses both the release worker, but also the orbit setup
experience endpoint, even though that is less likely.

_Checked the query against my host on dogfood where it took 0.1 seconds,
with the single host._

fixes: #31143 

_I also ended up putting my main test in a new file
`integration_mdm_release_worker_test.go` and decided not to do fancy
setup, as there is only one test so no recurring things, and based on
our retro talk also moved the setup experience related tests inside of
`integration_mdm_dep_test.go` into their separate file
`integration_mdm_setup_experience_test.go`_

# Checklist for submitter

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

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

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements)

## Testing

- [x] Added/updated automated tests

- [ ] QA'd all new/changed functionality manually (No, since this one is
hard to reproduce, but instead wrote an integration test before doing
the change to verify the behaviour.)
2025-07-31 17:50:57 +02:00

2725 lines
110 KiB
Go

package service
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"github.com/fleetdm/fleet/v4/pkg/fleetdbase"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/pkg/mdm/mdmtest"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
"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/service/contract"
"github.com/fleetdm/fleet/v4/server/worker"
kitlog "github.com/go-kit/log"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
micromdm "github.com/micromdm/micromdm/mdm/mdm"
"github.com/micromdm/plist"
"github.com/smallstep/pkcs7"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type profileAssignmentReq struct {
ProfileUUID string `json:"profile_uuid"`
Devices []string `json:"devices"`
}
func (s *integrationMDMTestSuite) TestDEPEnrollReleaseDeviceGlobal() {
t := s.T()
s.setSkipWorkerJobs(t)
ctx := context.Background()
s.enableABM(t.Name())
s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
encoder := json.NewEncoder(w)
switch r.URL.Path {
case "/session":
_, _ = w.Write([]byte(`{"auth_session_token": "session123"}`))
case "/account":
_, _ = w.Write([]byte(fmt.Sprintf(`{"admin_id": "admin123", "org_name": "%s"}`, "foo")))
case "/profile":
w.WriteHeader(http.StatusOK)
require.NoError(t, encoder.Encode(godep.ProfileResponse{ProfileUUID: "profile123"}))
}
}))
globalDevice := godep.Device{SerialNumber: uuid.New().String(), Model: "MacBook Pro", OS: "osx", OpType: "added"}
// set an enroll secret, the Fleetd configuration profile will be installed
// on the host
enrollSecret := "test-release-dep-device"
err := s.ds.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: enrollSecret}})
require.NoError(t, err)
// add a valid bootstrap package
b, err := os.ReadFile(filepath.Join("testdata", "bootstrap-packages", "signed.pkg"))
require.NoError(t, err)
signedPkg := b
s.uploadBootstrapPackage(&fleet.MDMAppleBootstrapPackage{Bytes: signedPkg, Name: "pkg.pkg", TeamID: 0}, http.StatusOK, "")
// add a custom setup assistant and ensure enable_release_device_manually is
// false (the default)
noTeamProf := `{"x": 1}`
s.Do("POST", "/api/latest/fleet/enrollment_profiles/automatic", createMDMAppleSetupAssistantRequest{
TeamID: nil,
Name: "no-team",
EnrollmentProfile: json.RawMessage(noTeamProf),
}, http.StatusOK)
payload := map[string]any{
"enable_release_device_manually": false,
}
s.Do("PATCH", "/api/latest/fleet/setup_experience", json.RawMessage(jsonMustMarshal(t, payload)), http.StatusNoContent)
// setup IdP so that AccountConfiguration profile is sent after DEP enrollment
var acResp appConfigResponse
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": {
"end_user_authentication": {
"entity_id": "https://localhost:8080",
"idp_name": "SimpleSAML",
"metadata_url": "http://localhost:9080/simplesaml/saml2/idp/metadata.php"
},
"macos_setup": {
"enable_end_user_authentication": true
}
}
}`), http.StatusOK, &acResp)
require.NotEmpty(t, acResp.MDM.EndUserAuthentication)
// TODO(mna): how/where to pass an enroll_reference so that
// runPostDEPEnrollment sends an AccountConfiguration command?
// add a global profile
globalProfile := mobileconfigForTest("N1", "I1")
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{globalProfile}}, http.StatusNoContent)
// enable FileVault
s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage([]byte(`{"mdm":{"macos_settings":{"enable_disk_encryption":true}}}`)), http.StatusOK)
s.enableABM("fleet_ade_test")
// add a setup experience script to run for no team
extraArgs := make(map[string][]string)
body, headers := generateNewScriptMultipartRequest(t,
"script.sh", []byte(`echo "hello"`), s.token, extraArgs)
s.DoRawWithHeaders("POST", "/api/latest/fleet/setup_experience/script", body.Bytes(), http.StatusOK, headers)
// test manual and automatic release with the new setup experience flow
for _, enableReleaseManually := range []bool{false, true} {
t.Run(fmt.Sprintf("enableReleaseManually=%t;new_flow", enableReleaseManually), func(t *testing.T) {
s.runDEPEnrollReleaseDeviceTest(t, globalDevice, DEPEnrollTestOpts{
EnableReleaseManually: enableReleaseManually,
TeamID: nil,
CustomProfileIdent: "I1",
UseOldFleetdFlow: false,
EnrollmentProfileFromDEPUsingPost: true,
})
})
}
// test manual and automatic release with the old worker flow
for _, enableReleaseManually := range []bool{false, true} {
t.Run(fmt.Sprintf("enableReleaseManually=%t;old_flow", enableReleaseManually), func(t *testing.T) {
s.runDEPEnrollReleaseDeviceTest(t, globalDevice, DEPEnrollTestOpts{
EnableReleaseManually: enableReleaseManually,
TeamID: nil,
CustomProfileIdent: "I1",
UseOldFleetdFlow: true,
})
})
}
// remove the setup experience script, run the new setup experience flow when
// there is no setup experience item to process (so it is bypassed)
s.Do("DELETE", "/api/latest/fleet/setup_experience/script", nil, http.StatusOK)
for _, enableReleaseManually := range []bool{false, true} {
t.Run(fmt.Sprintf("enableReleaseManually=%t;bypass_flow", enableReleaseManually), func(t *testing.T) {
s.runDEPEnrollReleaseDeviceTest(t, globalDevice, DEPEnrollTestOpts{
EnableReleaseManually: enableReleaseManually,
TeamID: nil,
CustomProfileIdent: "I1",
UseOldFleetdFlow: false,
EnrollmentProfileFromDEPUsingPost: true,
})
})
}
t.Run("manual agent install (no fleetd)", func(t *testing.T) {
s.Do("DELETE", "/api/latest/fleet/setup_experience/script", nil, http.StatusOK)
s.runDEPEnrollReleaseDeviceTest(t, globalDevice, DEPEnrollTestOpts{
EnableReleaseManually: false,
TeamID: nil,
CustomProfileIdent: "I1",
ManualAgentInstall: true,
})
})
}
func (s *integrationMDMTestSuite) TestDEPEnrollReleaseDeviceTeam() {
t := s.T()
s.setSkipWorkerJobs(t)
ctx := context.Background()
// Set up a mock DEP Apple API
s.enableABM(t.Name())
s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
encoder := json.NewEncoder(w)
switch r.URL.Path {
case "/session":
_, _ = w.Write([]byte(`{"auth_session_token": "session123"}`))
case "/account":
_, _ = w.Write([]byte(fmt.Sprintf(`{"admin_id": "admin123", "org_name": "%s"}`, "foo")))
case "/profile":
require.NoError(t, encoder.Encode(godep.ProfileResponse{ProfileUUID: "profile123"}))
}
}))
teamDevice := godep.Device{SerialNumber: uuid.New().String(), Model: "MacBook Pro", OS: "osx", OpType: "added"}
tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "test-team-device-release"})
require.NoError(t, err)
// set an enroll secret, the Fleetd configuration profile will be installed
// on the host
enrollSecret := "test-release-dep-device-team"
err = s.ds.ApplyEnrollSecrets(ctx, &tm.ID, []*fleet.EnrollSecret{{Secret: enrollSecret}})
require.NoError(t, err)
// add a valid bootstrap package
b, err := os.ReadFile(filepath.Join("testdata", "bootstrap-packages", "signed.pkg"))
require.NoError(t, err)
signedPkg := b
s.uploadBootstrapPackage(&fleet.MDMAppleBootstrapPackage{Bytes: signedPkg, Name: "pkg.pkg", TeamID: tm.ID}, http.StatusOK, "")
// add a custom setup assistant and ensure enable_release_device_manually is
// false (the default)
teamProf := `{"y": 2}`
s.Do("POST", "/api/latest/fleet/enrollment_profiles/automatic", createMDMAppleSetupAssistantRequest{
TeamID: &tm.ID,
Name: "team",
EnrollmentProfile: json.RawMessage(teamProf),
}, http.StatusOK)
payload := map[string]any{
"enable_release_device_manually": false,
}
s.Do("PATCH", "/api/latest/fleet/setup_experience", json.RawMessage(jsonMustMarshal(t, payload)), http.StatusNoContent)
// setup IdP so that AccountConfiguration profile is sent after DEP enrollment
var acResp appConfigResponse
s.enableABM("fleet_ade_test")
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
}],
"end_user_authentication": {
"entity_id": "https://localhost:8080",
"idp_name": "SimpleSAML",
"metadata_url": "http://localhost:9080/simplesaml/saml2/idp/metadata.php"
},
"macos_setup": {
"enable_end_user_authentication": true
}
}
}`, "fleet_ade_test", tm.Name, tm.Name, tm.Name)), http.StatusOK, &acResp)
require.NotEmpty(t, acResp.MDM.EndUserAuthentication)
// TODO(mna): how/where to pass an enroll_reference so that
// runPostDEPEnrollment sends an AccountConfiguration command?
// add a team profile
teamProfile := mobileconfigForTest("N2", "I2")
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{teamProfile}}, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID))
// enable FileVault
s.Do("PATCH", "/api/latest/fleet/mdm/apple/settings", json.RawMessage([]byte(fmt.Sprintf(`{"enable_disk_encryption":true,"team_id":%d}`, tm.ID))), http.StatusNoContent)
// add a setup experience script to run for this team
extraArgs := map[string][]string{
"team_id": {fmt.Sprintf("%d", tm.ID)},
}
body, headers := generateNewScriptMultipartRequest(t,
"script.sh", []byte(`echo "hello"`), s.token, extraArgs)
s.DoRawWithHeaders("POST", "/api/latest/fleet/setup_experience/script", body.Bytes(), http.StatusOK, headers)
// test manual and automatic release with the new setup experience flow
for _, enableReleaseManually := range []bool{false, true} {
t.Run(fmt.Sprintf("enableReleaseManually=%t;new_flow", enableReleaseManually), func(t *testing.T) {
s.runDEPEnrollReleaseDeviceTest(t, teamDevice, DEPEnrollTestOpts{
EnableReleaseManually: enableReleaseManually,
TeamID: &tm.ID,
CustomProfileIdent: "I2",
UseOldFleetdFlow: false,
EnrollmentProfileFromDEPUsingPost: true,
})
})
}
// test manual and automatic release with the old worker flow
for _, enableReleaseManually := range []bool{false, true} {
t.Run(fmt.Sprintf("enableReleaseManually=%t;old_flow", enableReleaseManually), func(t *testing.T) {
s.runDEPEnrollReleaseDeviceTest(t, teamDevice, DEPEnrollTestOpts{
EnableReleaseManually: enableReleaseManually,
TeamID: &tm.ID,
CustomProfileIdent: "I2",
UseOldFleetdFlow: true,
})
})
}
// remove the setup experience script, run the new setup experience flow when
// there is no setup experience item to process (so it is bypassed)
s.Do("DELETE", "/api/latest/fleet/setup_experience/script", nil, http.StatusOK, "team_id", fmt.Sprint(tm.ID))
for _, enableReleaseManually := range []bool{false, true} {
t.Run(fmt.Sprintf("enableReleaseManually=%t;bypass_flow", enableReleaseManually), func(t *testing.T) {
s.runDEPEnrollReleaseDeviceTest(t, teamDevice, DEPEnrollTestOpts{
EnableReleaseManually: enableReleaseManually,
TeamID: &tm.ID,
CustomProfileIdent: "I2",
UseOldFleetdFlow: false,
EnrollmentProfileFromDEPUsingPost: true,
})
})
}
t.Run("manual agent install (no fleetd)", func(t *testing.T) {
s.Do("DELETE", "/api/latest/fleet/setup_experience/script", nil, http.StatusOK, "team_id", fmt.Sprint(tm.ID))
s.runDEPEnrollReleaseDeviceTest(t, teamDevice, DEPEnrollTestOpts{
EnableReleaseManually: true,
TeamID: &tm.ID,
CustomProfileIdent: "I2",
ManualAgentInstall: true,
})
})
}
func (s *integrationMDMTestSuite) TestDEPEnrollReleaseIphoneTeam() {
t := s.T()
s.setSkipWorkerJobs(t)
ctx := context.Background()
// Set up a mock DEP Apple API
s.enableABM(t.Name())
s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
encoder := json.NewEncoder(w)
switch r.URL.Path {
case "/session":
_, _ = w.Write([]byte(`{"auth_session_token": "session123"}`))
case "/account":
_, _ = w.Write([]byte(fmt.Sprintf(`{"admin_id": "admin123", "org_name": "%s"}`, "foo")))
case "/profile":
require.NoError(t, encoder.Encode(godep.ProfileResponse{ProfileUUID: "profile123"}))
}
}))
teamDevice := godep.Device{SerialNumber: "IOS0_SERIAL", Model: "iPhone 16 Pro", OS: "ios", DeviceFamily: "iPhone", OpType: "added"}
tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "test-team-device-release"})
require.NoError(t, err)
enrollSecret := "test-release-dep-device-team"
err = s.ds.ApplyEnrollSecrets(ctx, &tm.ID, []*fleet.EnrollSecret{{Secret: enrollSecret}})
require.NoError(t, err)
// add a custom setup assistant and ensure enable_release_device_manually is
// false (the default)
teamProf := `{"y": 2}`
s.Do("POST", "/api/latest/fleet/enrollment_profiles/automatic", createMDMAppleSetupAssistantRequest{
TeamID: &tm.ID,
Name: "team",
EnrollmentProfile: json.RawMessage(teamProf),
}, http.StatusOK)
payload := map[string]any{
"enable_release_device_manually": false,
}
s.Do("PATCH", "/api/latest/fleet/setup_experience", json.RawMessage(jsonMustMarshal(t, payload)), http.StatusNoContent)
var acResp appConfigResponse
s.enableABM("fleet_ade_test")
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
}]
}
}`, "fleet_ade_test", tm.Name, tm.Name, tm.Name)), http.StatusOK, &acResp)
// add a team profile
teamProfile := mobileconfigForTest("N2", "I2")
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{teamProfile}}, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID))
for _, enableReleaseManually := range []bool{false, true} {
t.Run(fmt.Sprintf("enableReleaseManually=%t", enableReleaseManually), func(t *testing.T) {
s.runDEPEnrollReleaseDeviceTest(t, teamDevice, DEPEnrollTestOpts{
EnableReleaseManually: enableReleaseManually,
TeamID: &tm.ID,
CustomProfileIdent: "I2",
UseOldFleetdFlow: false,
EnrollmentProfileFromDEPUsingPost: true,
})
})
}
}
// DEPEnrollTestOpts contains options for DEP enrollment and release device tests
type DEPEnrollTestOpts struct {
EnableReleaseManually bool
TeamID *uint
CustomProfileIdent string
UseOldFleetdFlow bool
ManualAgentInstall bool
EnrollmentProfileFromDEPUsingPost bool
}
func (s *integrationMDMTestSuite) runDEPEnrollReleaseDeviceTest(t *testing.T, device godep.Device, opts DEPEnrollTestOpts) {
ctx := context.Background()
var isIphone bool
if device.DeviceFamily == "iPhone" {
isIphone = true
}
// set the enable release device manually option
payload := map[string]any{
"enable_release_device_manually": opts.EnableReleaseManually,
"manual_agent_install": opts.ManualAgentInstall,
}
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
payload["manual_agent_install"] = 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("fleet_ade_test", 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)
})
// 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...)
if isIphone {
mdmDevice.Model = "iPhone 14,6"
}
mdmDevice.SerialNumber = device.SerialNumber
err := mdmDevice.Enroll()
require.NoError(t, err)
// check if it has setup experience items or not
hasSetupExpItems := true
_, err = s.ds.GetHostAwaitingConfiguration(ctx, mdmDevice.UUID)
if fleet.IsNotFound(err) {
hasSetupExpItems = false
} else if err != nil {
require.NoError(t, err)
}
// 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 cmd != nil {
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.CommandUUID, cmd.Command.RequestType)
// }
cmds = append(cmds, &fullCmd)
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
}
if isIphone {
// expected commands: install CA, install profile (only the custom one),
// not expected: account configuration, since enrollment_reference not set
require.Len(t, cmds, 2)
} else {
// expected commands: install fleetd, install bootstrap, install CA, install profiles
// (custom one, fleetd configuration, FileVault) (not expected: account
// configuration, since enrollment_reference not set)
expectedCommands := 6
if opts.ManualAgentInstall {
expectedCommands--
}
assert.Len(t, cmds, expectedCommands)
}
var installProfileCount, installEnterpriseCount, otherCount int
var profileCustomSeen, profileFleetdSeen, profileFleetCASeen, profileFileVaultSeen bool
var lastInstallEnterpriseApplication *micromdm.InstallEnterpriseApplication
for _, cmd := range cmds {
switch cmd.Command.RequestType {
case "InstallProfile":
installProfileCount++
if strings.Contains(string(cmd.Command.InstallProfile.Payload), //nolint:gocritic // ignore ifElseChain
fmt.Sprintf("<string>%s</string>", opts.CustomProfileIdent)) {
profileCustomSeen = true
} else if strings.Contains(string(cmd.Command.InstallProfile.Payload), fmt.Sprintf("<string>%s</string>", mobileconfig.FleetdConfigPayloadIdentifier)) {
profileFleetdSeen = true
} else if strings.Contains(string(cmd.Command.InstallProfile.Payload), fmt.Sprintf("<string>%s</string>", mobileconfig.FleetCARootConfigPayloadIdentifier)) {
profileFleetCASeen = true
} else if 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++
lastInstallEnterpriseApplication = cmd.Command.InstallEnterpriseApplication
default:
otherCount++
}
}
if isIphone {
require.Equal(t, 2, installProfileCount)
require.Equal(t, 0, installEnterpriseCount)
require.Equal(t, 0, otherCount)
require.True(t, profileCustomSeen)
require.False(t, profileFleetdSeen)
require.True(t, profileFleetCASeen)
require.False(t, profileFileVaultSeen)
// for iDevices, fleetd is not installed so the rest of this test does not apply.
if opts.EnableReleaseManually {
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
mysql.DumpTable(t, q, "jobs")
return nil
})
// 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, 1, time.Now().UTC().Add(time.Minute))
require.NoError(t, err)
require.Empty(t, pending)
} else {
// otherwise the device release job should be enqueued
pending, err := s.ds.GetQueuedJobs(ctx, 1, time.Now().UTC().Add(time.Minute))
require.NoError(t, err)
require.Len(t, pending, 1)
require.Equal(t, "apple_mdm", pending[0].Name)
require.Contains(t, string(*pending[0].Args), worker.AppleMDMPostDEPReleaseDeviceTask)
// make the pending job 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 = ?`, time.Now().Add(-1*time.Minute).UTC(), pending[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 {
switch cmd.Command.RequestType {
case "DeviceConfigured":
deviceConfiguredCount++
default:
otherCount++
}
}
require.Equal(t, 1, deviceConfiguredCount)
require.Equal(t, 0, otherCount)
}
return
}
require.Equal(t, 4, installProfileCount)
expectedInstallEnterpriseCount := 2
if opts.ManualAgentInstall {
expectedInstallEnterpriseCount--
require.NotNil(t, lastInstallEnterpriseApplication)
require.NotNil(t, lastInstallEnterpriseApplication.Manifest)
require.GreaterOrEqual(t, len(lastInstallEnterpriseApplication.Manifest.ManifestItems), 1)
require.Len(t, lastInstallEnterpriseApplication.Manifest.ManifestItems[0].Assets, 1)
assert.Contains(t, lastInstallEnterpriseApplication.Manifest.ManifestItems[0].Assets[0].URL, "fleet/mdm/bootstrap")
}
require.Equal(t, expectedInstallEnterpriseCount, installEnterpriseCount)
require.Equal(t, 0, otherCount)
require.True(t, profileCustomSeen)
require.True(t, profileFleetdSeen)
require.True(t, profileFleetCASeen)
require.True(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
// call the /config endpoint as fleetd would
var orbitConfigResp orbitGetConfigResponse
var caps fleet.CapabilityMap
if opts.UseOldFleetdFlow {
// important thing is that it doesn't have the CapabilitySetupExperience
caps.PopulateFromString(string(fleet.CapabilityEscrowBuddy))
} else {
caps = fleet.GetOrbitClientCapabilities()
}
res := s.DoRawWithHeaders("POST", "/api/fleet/orbit/config", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *enrolledHost.OrbitNodeKey)),
http.StatusOK, map[string]string{fleet.CapabilitiesHeader: caps.String()})
b, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.NoError(t, json.Unmarshal(b, &orbitConfigResp))
if hasSetupExpItems {
// should be notified of the setup experience flow
require.True(t, orbitConfigResp.Notifications.RunSetupExperience)
} else {
// should bypass the setup experience flow
require.False(t, orbitConfigResp.Notifications.RunSetupExperience)
}
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, 1, time.Now().UTC().Add(time.Minute))
require.NoError(t, err)
require.Empty(t, pending)
return
}
if opts.UseOldFleetdFlow || !hasSetupExpItems {
// there should be a Release Device pending job
pending, err := s.ds.GetQueuedJobs(ctx, 2, time.Now().UTC().Add(time.Minute))
require.NoError(t, err)
require.Len(t, pending, 1)
require.Equal(t, "apple_mdm", pending[0].Name)
require.Contains(t, string(*pending[0].Args), worker.AppleMDMPostDEPReleaseDeviceTask)
// calling the orbit config endpoint again does NOT enqueue a new job, and doesn't
// return the RunSetupExperience notification anymore
orbitConfigResp = orbitGetConfigResponse{}
res := s.DoRawWithHeaders("POST", "/api/fleet/orbit/config", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *enrolledHost.OrbitNodeKey)),
http.StatusOK, map[string]string{fleet.CapabilitiesHeader: caps.String()})
b, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.NoError(t, json.Unmarshal(b, &orbitConfigResp))
require.False(t, orbitConfigResp.Notifications.RunSetupExperience)
pending, err = s.ds.GetQueuedJobs(ctx, 2, time.Now().UTC().Add(time.Minute))
require.NoError(t, err)
require.Len(t, pending, 1)
// make the pending job 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 = ?`, time.Now().Add(-1*time.Minute).UTC(), pending[0].ID)
return err
})
s.runWorker()
} else {
// there shouldn't be a Release Device pending job anymore
pending, err := s.ds.GetQueuedJobs(ctx, 1, time.Now().UTC().Add(time.Minute))
require.NoError(t, err)
require.Len(t, pending, 0)
// mark the setup experience script as done
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `UPDATE setup_experience_status_results SET status = 'success' WHERE host_uuid = ?`, mdmDevice.UUID)
return err
})
// call the /status endpoint to automatically release the host
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)
}
// 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 {
switch cmd.Command.RequestType {
case "DeviceConfigured":
deviceConfiguredCount++
default:
otherCount++
}
}
require.Equal(t, 1, deviceConfiguredCount)
require.Equal(t, 0, otherCount)
}
func (s *integrationMDMTestSuite) TestDEPProfileAssignment() {
t := s.T()
ctx := context.Background()
devices := []godep.Device{
{SerialNumber: uuid.New().String(), Model: "MacBook Pro", OS: "osx", OpType: "added"},
{SerialNumber: uuid.New().String(), Model: "MacBook Mini", OS: "osx", OpType: "added"},
{SerialNumber: uuid.New().String(), Model: "MacBook Mini", OS: "osx", OpType: ""},
{SerialNumber: uuid.New().String(), Model: "MacBook Mini", OS: "osx", OpType: "modified"},
}
// set release device manually to true so there is no job enqueued at a later
// time to release the device (this is not what this test is about)
s.Do("PATCH", "/api/latest/fleet/setup_experience", json.RawMessage(jsonMustMarshal(t, map[string]any{
"enable_release_device_manually": true,
})), http.StatusNoContent)
profileAssignmentReqs := []profileAssignmentReq{}
// add global profiles
globalProfile := mobileconfigForTest("N1", "I1")
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{globalProfile}}, http.StatusNoContent)
checkPostEnrollmentCommands := func(mdmDevice *mdmtest.TestAppleMDMClient, shouldReceive bool) {
// run the worker to process the DEP enroll request
s.runWorker()
// run the worker to assign configuration profiles
s.awaitTriggerProfileSchedule(t)
var fleetdCmd, installProfileCmd *micromdm.CommandPayload
cmd, err := mdmDevice.Idle()
require.NoError(t, err)
for cmd != nil {
var fullCmd micromdm.CommandPayload
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
if fullCmd.Command.RequestType == "InstallEnterpriseApplication" &&
fullCmd.Command.InstallEnterpriseApplication.ManifestURL != nil &&
strings.Contains(*fullCmd.Command.InstallEnterpriseApplication.ManifestURL, fleetdbase.GetPKGManifestURL()) {
fleetdCmd = &fullCmd
} else if cmd.Command.RequestType == "InstallProfile" {
installProfileCmd = &fullCmd
}
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
}
if shouldReceive {
// received request to install fleetd
require.NotNil(t, fleetdCmd, "host didn't get a command to install fleetd")
require.NotNil(t, fleetdCmd.Command, "host didn't get a command to install fleetd")
// received request to install the global configuration profile
require.NotNil(t, installProfileCmd, "host didn't get a command to install profiles")
require.NotNil(t, installProfileCmd.Command, "host didn't get a command to install profiles")
} else {
require.Nil(t, fleetdCmd, "host got a command to install fleetd")
require.Nil(t, installProfileCmd, "host got a command to install profiles")
}
}
checkAssignProfileRequests := func(serial string, profUUID *string) {
require.NotEmpty(t, profileAssignmentReqs)
require.Len(t, profileAssignmentReqs, 1)
require.Len(t, profileAssignmentReqs[0].Devices, 1)
require.Equal(t, serial, profileAssignmentReqs[0].Devices[0])
if profUUID != nil {
require.Equal(t, *profUUID, profileAssignmentReqs[0].ProfileUUID)
}
}
type hostDEPRow struct {
HostID uint `db:"host_id"`
ProfileUUID string `db:"profile_uuid"`
AssignProfileResponse string `db:"assign_profile_response"`
ResponseUpdatedAt time.Time `db:"response_updated_at"`
RetryJobID uint `db:"retry_job_id"`
DeletedAt *time.Time `db:"deleted_at"`
}
checkHostDEPAssignProfileResponses := func(deviceSerials []string, expectedProfileUUID string, expectedStatus fleet.DEPAssignProfileResponseStatus) map[string]hostDEPRow {
bySerial := make(map[string]hostDEPRow, len(deviceSerials))
for _, deviceSerial := range deviceSerials {
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
var dest hostDEPRow
err := sqlx.GetContext(ctx, q, &dest, "SELECT host_id, assign_profile_response, profile_uuid, response_updated_at, retry_job_id, deleted_at FROM host_dep_assignments WHERE profile_uuid = ? AND host_id = (SELECT id FROM hosts WHERE hardware_serial = ?)", expectedProfileUUID, deviceSerial)
require.NoError(t, err)
require.Equal(t, string(expectedStatus), dest.AssignProfileResponse)
bySerial[deviceSerial] = dest
return nil
})
}
return bySerial
}
checkPendingMacOSSetupAssistantJob := func(expectedTask string, expectedTeamID *uint, expectedSerials []string, expectedJobID uint) {
pending, err := s.ds.GetQueuedJobs(context.Background(), 1, time.Time{})
require.NoError(t, err)
require.Len(t, pending, 1)
require.Equal(t, "macos_setup_assistant", pending[0].Name)
require.NotNil(t, pending[0].Args)
var gotArgs struct {
Task string `json:"task"`
TeamID *uint `json:"team_id,omitempty"`
HostSerialNumbers []string `json:"host_serial_numbers,omitempty"`
}
require.NoError(t, json.Unmarshal(*pending[0].Args, &gotArgs))
require.Equal(t, expectedTask, gotArgs.Task)
if expectedTeamID != nil {
require.NotNil(t, gotArgs.TeamID)
require.Equal(t, *expectedTeamID, *gotArgs.TeamID)
} else {
require.Nil(t, gotArgs.TeamID)
}
require.Equal(t, expectedSerials, gotArgs.HostSerialNumbers)
if expectedJobID != 0 {
require.Equal(t, expectedJobID, pending[0].ID)
}
}
checkNoJobsPending := func() {
pending, err := s.ds.GetQueuedJobs(context.Background(), 1, time.Time{})
require.NoError(t, err)
if len(pending) > 0 {
t.Logf("found unexpected pending job %s scheduled for %v ('now' is %v):\n%s\n", pending[0].Name, pending[0].NotBefore, time.Now().UTC(), string(*pending[0].Args))
}
require.Empty(t, pending)
}
expectNoJobID := ptr.Uint(0) // used when expect no retry job
checkHostCooldown := func(serial, profUUID string, status fleet.DEPAssignProfileResponseStatus, expectUpdatedAt *time.Time, expectRetryJobID *uint) hostDEPRow {
bySerial := checkHostDEPAssignProfileResponses([]string{serial}, profUUID, status)
d, ok := bySerial[serial]
require.True(t, ok)
if expectUpdatedAt != nil {
require.Equal(t, *expectUpdatedAt, d.ResponseUpdatedAt)
}
if expectRetryJobID != nil {
require.Equal(t, *expectRetryJobID, d.RetryJobID)
}
return d
}
checkListHostDEPError := func(serial string, expectStatus string, expectError bool) *fleet.HostResponse {
listHostsRes := listHostsResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts?query=%s", serial), nil, http.StatusOK, &listHostsRes)
require.Len(t, listHostsRes.Hosts, 1)
require.Equal(t, serial, listHostsRes.Hosts[0].HardwareSerial)
require.Equal(t, expectStatus, *listHostsRes.Hosts[0].MDM.EnrollmentStatus)
require.Equal(t, expectError, listHostsRes.Hosts[0].MDM.DEPProfileError)
return &listHostsRes.Hosts[0]
}
setAssignProfileResponseUpdatedAt := func(serial string, updatedAt time.Time) {
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `UPDATE host_dep_assignments SET response_updated_at = ? WHERE host_id = (SELECT id FROM hosts WHERE hardware_serial = ?)`, updatedAt, serial)
return err
})
}
expectAssignProfileResponseFailed := "" // set to device serial when testing the failed profile assignment flow
expectAssignProfileResponseNotAccessible := "" // set to device serial when testing the not accessible profile assignment flow
s.enableABM(t.Name())
s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
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":
// This endpoint is used to get an initial list of
// devices, return a single device
err := encoder.Encode(godep.DeviceResponse{Devices: devices[:1]})
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: devices, 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))
profileAssignmentReqs = append(profileAssignmentReqs, prof)
var resp godep.ProfileResponse
resp.ProfileUUID = prof.ProfileUUID
resp.Devices = make(map[string]string, len(prof.Devices))
for _, device := range prof.Devices {
switch device {
case expectAssignProfileResponseNotAccessible:
resp.Devices[device] = string(fleet.DEPAssignProfileResponseNotAccessible)
case expectAssignProfileResponseFailed:
resp.Devices[device] = string(fleet.DEPAssignProfileResponseFailed)
default:
resp.Devices[device] = string(fleet.DEPAssignProfileResponseSuccess)
}
}
err = encoder.Encode(resp)
require.NoError(t, err)
default:
_, _ = w.Write([]byte(`{}`))
}
}))
// query all hosts
listHostsRes := listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
require.Empty(t, listHostsRes.Hosts)
// trigger a profile sync
s.runDEPSchedule()
// all hosts should be returned from the hosts endpoint
listHostsRes = listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
require.Len(t, listHostsRes.Hosts, len(devices))
var wantSerials []string
var gotSerials []string
for i, device := range devices {
wantSerials = append(wantSerials, device.SerialNumber)
gotSerials = append(gotSerials, listHostsRes.Hosts[i].HardwareSerial)
// entries for all hosts should be created in the host_dep_assignments table
_, err := s.ds.GetHostDEPAssignment(ctx, listHostsRes.Hosts[i].ID)
require.NoError(t, err)
}
require.ElementsMatch(t, wantSerials, gotSerials)
// called two times:
// - one when we get the initial list of devices (/server/devices)
// - one when we do the device sync (/device/sync)
require.Len(t, profileAssignmentReqs, 2)
require.Len(t, profileAssignmentReqs[0].Devices, 1)
checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess)
require.Len(t, profileAssignmentReqs[1].Devices, len(devices))
checkHostDEPAssignProfileResponses(profileAssignmentReqs[1].Devices, profileAssignmentReqs[1].ProfileUUID, fleet.DEPAssignProfileResponseSuccess)
// record the default profile to be used in other tests
defaultProfileUUID := profileAssignmentReqs[1].ProfileUUID
// create a new host
nonDEPHost := createHostAndDeviceToken(t, s.ds, "not-dep")
listHostsRes = listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
require.Len(t, listHostsRes.Hosts, len(devices)+1)
// filtering by MDM status works
listHostsRes = listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts?mdm_enrollment_status=pending", nil, http.StatusOK, &listHostsRes)
require.Len(t, listHostsRes.Hosts, len(devices))
// searching by display name works
listHostsRes = listHostsResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts?query=%s", url.QueryEscape("MacBook Mini")), nil, http.StatusOK, &listHostsRes)
require.Len(t, listHostsRes.Hosts, 3)
for _, host := range listHostsRes.Hosts {
require.Equal(t, "MacBook Mini", host.HardwareModel)
require.Equal(t, host.DisplayName, fmt.Sprintf("MacBook Mini (%s)", host.HardwareSerial))
}
s.pushProvider.PushFunc = func(_ context.Context, pushes []*mdm.Push) (map[string]*push.Response, error) {
return map[string]*push.Response{}, nil
}
// Enroll one of the hosts
depURLToken := loadEnrollmentProfileDEPToken(t, s.ds)
mdmDevice := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, depURLToken)
mdmDevice.SerialNumber = devices[0].SerialNumber
err := mdmDevice.Enroll()
require.NoError(t, err)
// make sure the host gets post enrollment requests
checkPostEnrollmentCommands(mdmDevice, true)
// only one shows up as pending
listHostsRes = listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts?mdm_enrollment_status=pending", nil, http.StatusOK, &listHostsRes)
require.Len(t, listHostsRes.Hosts, len(devices)-1)
activities := listActivitiesResponse{}
s.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, &activities, "order_key", "created_at")
found := false
for _, activity := range activities.Activities {
if activity.Type == "mdm_enrolled" &&
strings.Contains(string(*activity.Details), devices[0].SerialNumber) {
found = true
require.Nil(t, activity.ActorID)
require.Nil(t, activity.ActorFullName)
require.JSONEq(
t,
fmt.Sprintf(
`{"host_serial": "%s", "enrollment_id": null, "host_display_name": "%s (%s)", "installed_from_dep": true, "mdm_platform": "apple"}`,
devices[0].SerialNumber, devices[0].Model, devices[0].SerialNumber,
),
string(*activity.Details),
)
}
}
require.True(t, found)
// add devices[1].SerialNumber to a team
teamName := t.Name() + "team1"
team := &fleet.Team{
Name: teamName,
Description: "desc team1",
}
var createTeamResp teamResponse
s.DoJSON("POST", "/api/latest/fleet/teams", team, http.StatusOK, &createTeamResp)
require.NotZero(t, createTeamResp.Team.ID)
team = createTeamResp.Team
var device1ID uint
for _, h := range listHostsRes.Hosts {
if h.HardwareSerial == devices[1].SerialNumber {
err = s.ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team.ID, []uint{h.ID}))
require.NoError(t, err)
device1ID = h.ID
break
}
}
// modify the response and trigger another sync to include:
//
// 1. A repeated device with "added"
// 2. A repeated device with "modified"
// 3. A device with "deleted"
// 4. A new device
deletedSerial := devices[2].SerialNumber
addedSerial := uuid.New().String()
devices = []godep.Device{
{SerialNumber: devices[0].SerialNumber, Model: "MacBook Pro", OS: "osx", OpType: "added"},
{SerialNumber: devices[1].SerialNumber, Model: "MacBook Mini", OS: "osx", OpType: "modified"},
{SerialNumber: deletedSerial, Model: "MacBook Mini", OS: "osx", OpType: "deleted"},
{SerialNumber: addedSerial, Model: "MacBook Mini", OS: "osx", OpType: "added"},
}
profileAssignmentReqs = []profileAssignmentReq{}
// Enroll the host to be deleted. It will stay in Fleet after deletion from DEP.
mdmDeviceToDelete := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, depURLToken)
mdmDeviceToDelete.SerialNumber = deletedSerial
require.NoError(t, mdmDeviceToDelete.Enroll())
// make sure the host gets post enrollment requests
checkPostEnrollmentCommands(mdmDeviceToDelete, true)
s.runDEPSchedule()
// all hosts should be returned from the hosts endpoint
listHostsRes = listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
// all previous devices + the manually added host + the new `addedSerial`
wantSerials = append(wantSerials, devices[3].SerialNumber, nonDEPHost.HardwareSerial)
require.Len(t, listHostsRes.Hosts, len(wantSerials))
gotSerials = []string{}
var deletedHostID uint
var addedHostID uint
var mdmDeviceID uint
for _, device := range listHostsRes.Hosts {
gotSerials = append(gotSerials, device.HardwareSerial)
switch device.HardwareSerial {
case deletedSerial:
deletedHostID = device.ID
case addedSerial:
addedHostID = device.ID
case mdmDevice.SerialNumber:
mdmDeviceID = device.ID
}
}
require.ElementsMatch(t, wantSerials, gotSerials)
require.Len(t, profileAssignmentReqs, 3)
// first request to get a list of profiles
// TODO: seems like we're doing this request on each loop?
require.Len(t, profileAssignmentReqs[0].Devices, 1)
require.Equal(t, devices[0].SerialNumber, profileAssignmentReqs[0].Devices[0])
checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess)
// profileAssignmentReqs[1] and [2] can be in any order
ix2Devices, ix1Device := 1, 2
if len(profileAssignmentReqs[1].Devices) == 1 {
ix2Devices, ix1Device = ix1Device, ix2Devices
}
// - existing device with "added"
// - new device with "added"
require.Len(t, profileAssignmentReqs[ix2Devices].Devices, 2, "%#+v", profileAssignmentReqs)
require.ElementsMatch(t, []string{devices[0].SerialNumber, addedSerial}, profileAssignmentReqs[ix2Devices].Devices)
checkHostDEPAssignProfileResponses(profileAssignmentReqs[ix2Devices].Devices, profileAssignmentReqs[ix2Devices].ProfileUUID, fleet.DEPAssignProfileResponseSuccess)
// - existing device with "modified" and a different team (thus different profile request)
require.Len(t, profileAssignmentReqs[ix1Device].Devices, 1)
require.Equal(t, devices[1].SerialNumber, profileAssignmentReqs[ix1Device].Devices[0])
checkHostDEPAssignProfileResponses(profileAssignmentReqs[ix1Device].Devices, profileAssignmentReqs[ix1Device].ProfileUUID, fleet.DEPAssignProfileResponseSuccess)
// entries for all hosts except for the one with OpType = "deleted"
assignment, err := s.ds.GetHostDEPAssignment(ctx, deletedHostID)
require.NoError(t, err)
require.NotZero(t, assignment.DeletedAt)
_, err = s.ds.GetHostDEPAssignment(ctx, addedHostID)
require.NoError(t, err)
// send a TokenUpdate command, it shouldn't re-send the post-enrollment commands
err = mdmDevice.TokenUpdate(false)
require.NoError(t, err)
checkPostEnrollmentCommands(mdmDevice, false)
// enroll the device again, it should get the post-enrollment commands
err = mdmDevice.Enroll()
require.NoError(t, err)
checkPostEnrollmentCommands(mdmDevice, true)
// delete the device from Fleet
var delResp deleteHostResponse
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d", mdmDeviceID), nil, http.StatusOK, &delResp)
// the device comes back as pending
listHostsRes = listHostsResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts?query=%s", mdmDevice.UUID), nil, http.StatusOK, &listHostsRes)
require.Len(t, listHostsRes.Hosts, 1)
require.Equal(t, mdmDevice.SerialNumber, listHostsRes.Hosts[0].HardwareSerial)
// we assign a DEP profile to the device
profileAssignmentReqs = []profileAssignmentReq{}
s.runWorker()
require.Equal(t, mdmDevice.SerialNumber, profileAssignmentReqs[0].Devices[0])
checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess)
// it should get the post-enrollment commands
require.NoError(t, mdmDevice.Enroll())
checkPostEnrollmentCommands(mdmDevice, true)
// Delete a pending device from DEP
addedModifiedDeletedSerial := uuid.NewString() // no-op
deletedAddedSerial := devices[0].SerialNumber // stay as is
deletedSerial = devices[1].SerialNumber
devices = []godep.Device{
{SerialNumber: addedModifiedDeletedSerial, Model: "MacBook Pro", OS: "osx", OpType: "added", OpDate: time.Now()},
{
SerialNumber: addedModifiedDeletedSerial, Model: "MacBook Pro", OS: "osx", OpType: "modified",
OpDate: time.Now().Add(time.Second),
},
{
SerialNumber: addedModifiedDeletedSerial, Model: "MacBook Pro", OS: "osx", OpType: "deleted",
OpDate: time.Now().Add(2 * time.Second),
},
{
SerialNumber: addedModifiedDeletedSerial, Model: "MacBook Pro", OS: "osx", OpType: "added",
OpDate: time.Now().Add(3 * time.Second),
},
{
SerialNumber: addedModifiedDeletedSerial, Model: "MacBook Pro", OS: "osx", OpType: "deleted",
OpDate: time.Now().Add(4 * time.Second),
},
{SerialNumber: deletedAddedSerial, Model: "MacBook Pro", OS: "osx", OpType: "deleted", OpDate: time.Now()},
{SerialNumber: deletedAddedSerial, Model: "MacBook Pro", OS: "osx", OpType: "added", OpDate: time.Now().Add(time.Second)},
{SerialNumber: deletedAddedSerial, Model: "MacBook Pro", OS: "osx", OpType: "added", OpDate: time.Now().Add(2 * time.Second)},
{SerialNumber: deletedSerial, Model: "MacBook Mini", OS: "osx", OpType: "modified", OpDate: time.Now()},
{SerialNumber: deletedSerial, Model: "MacBook Mini", OS: "osx", OpType: "modified", OpDate: time.Now().Add(time.Second)},
{SerialNumber: deletedSerial, Model: "MacBook Mini", OS: "osx", OpType: "deleted", OpDate: time.Now().Add(2 * time.Second)},
}
profileAssignmentReqs = []profileAssignmentReq{}
// Check that host display name is present for the device to be deleted; later we will check that it has been deleted
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
var dest uint
return sqlx.GetContext(ctx, q, &dest,
"SELECT 1 FROM host_display_names WHERE host_id = ?", device1ID)
})
s.runDEPSchedule()
// all hosts should be returned from the hosts endpoint
listHostsRes = listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
// all previous devices minus the pending deleted one
wantSerials = slices.DeleteFunc(wantSerials, func(s string) bool { return s == deletedSerial })
assert.Len(t, listHostsRes.Hosts, len(wantSerials))
gotSerials = []string{}
for _, device := range listHostsRes.Hosts {
gotSerials = append(gotSerials, device.HardwareSerial)
}
assert.ElementsMatch(t, wantSerials, gotSerials)
assert.Len(t, profileAssignmentReqs, 2)
gotSerials = []string{}
for _, req := range profileAssignmentReqs {
assert.Len(t, req.Devices, 1)
gotSerials = append(gotSerials, req.Devices...)
}
assert.ElementsMatch(t, []string{addedModifiedDeletedSerial, deletedAddedSerial}, gotSerials)
// Check that host display name was deleted
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
var dest uint
return sqlx.GetContext(ctx, q, &dest,
"SELECT 1 FROM host_display_names WHERE NOT EXISTS (SELECT 1 FROM host_display_names WHERE host_id = ?)", device1ID)
})
// delete all MDM info
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `DELETE FROM host_mdm WHERE host_id = ?`, listHostsRes.Hosts[0].ID)
return err
})
// it should still get the post-enrollment commands
require.NoError(t, mdmDevice.Enroll())
checkPostEnrollmentCommands(mdmDevice, true)
// The user unenrolls from Fleet (e.g. was DEP enrolled but with `is_mdm_removable: true`
// so the user removes the enrollment profile).
err = mdmDevice.Checkout()
require.NoError(t, err)
// Simulate a refetch where we clean up the MDM data since the host is not enrolled anymore
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `DELETE FROM host_mdm WHERE host_id = ?`, mdmDeviceID)
return err
})
// Simulate fleetd re-enrolling automatically.
err = mdmDevice.Enroll()
require.NoError(t, err)
// The last activity should have `installed_from_dep=true`.
s.lastActivityMatches(
"mdm_enrolled",
fmt.Sprintf(
`{"host_serial": "%s", "enrollment_id": null, "host_display_name": "%s (%s)", "installed_from_dep": true, "mdm_platform": "apple"}`,
mdmDevice.SerialNumber, mdmDevice.Model, mdmDevice.SerialNumber,
),
0,
)
// enroll a host into Fleet
eHost, err := s.ds.NewHost(context.Background(), &fleet.Host{
ID: 1,
OsqueryHostID: ptr.String("Desktop-ABCQWE"),
NodeKey: ptr.String("Desktop-ABCQWE"),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%sfoo.local", s.T().Name()),
Platform: "darwin",
HardwareSerial: uuid.New().String(),
})
require.NoError(t, err)
// on team transfer, we don't assign a DEP profile to the device
s.Do("POST", "/api/v1/fleet/hosts/transfer",
addHostsToTeamRequest{TeamID: &team.ID, HostIDs: []uint{eHost.ID}}, http.StatusOK)
profileAssignmentReqs = []profileAssignmentReq{}
s.runWorker()
require.Empty(t, profileAssignmentReqs)
// assign the host in ABM
devices = []godep.Device{
{SerialNumber: eHost.HardwareSerial, Model: "MacBook Pro", OS: "osx", OpType: "modified"},
}
profileAssignmentReqs = []profileAssignmentReq{}
s.runDEPSchedule()
require.NotEmpty(t, profileAssignmentReqs)
require.Equal(t, eHost.HardwareSerial, profileAssignmentReqs[0].Devices[0])
checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess)
// report MDM info via osquery
require.NoError(t, s.ds.SetOrUpdateMDMData(ctx, eHost.ID, false, true, s.server.URL, true, fleet.WellKnownMDMFleet, "", false))
checkListHostDEPError(eHost.HardwareSerial, "On (automatic)", false)
// transfer to "no team", we assign a DEP profile to the device
profileAssignmentReqs = []profileAssignmentReq{}
s.Do("POST", "/api/v1/fleet/hosts/transfer",
addHostsToTeamRequest{TeamID: nil, HostIDs: []uint{eHost.ID}}, http.StatusOK)
s.runWorker()
require.NotEmpty(t, profileAssignmentReqs)
require.Equal(t, eHost.HardwareSerial, profileAssignmentReqs[0].Devices[0])
checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess)
checkListHostDEPError(eHost.HardwareSerial, "On (automatic)", false)
// transfer to the team back again, we assign a DEP profile to the device again
s.Do("POST", "/api/v1/fleet/hosts/transfer",
addHostsToTeamRequest{TeamID: &team.ID, HostIDs: []uint{eHost.ID}}, http.StatusOK)
profileAssignmentReqs = []profileAssignmentReq{}
s.runWorker()
require.NotEmpty(t, profileAssignmentReqs)
require.Equal(t, eHost.HardwareSerial, profileAssignmentReqs[0].Devices[0])
checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess)
checkListHostDEPError(eHost.HardwareSerial, "On (automatic)", false)
// transfer to "no team", but simulate a failed profile assignment
expectAssignProfileResponseFailed = eHost.HardwareSerial
profileAssignmentReqs = []profileAssignmentReq{}
s.Do("POST", "/api/v1/fleet/hosts/transfer",
addHostsToTeamRequest{TeamID: nil, HostIDs: []uint{eHost.ID}}, http.StatusOK)
checkPendingMacOSSetupAssistantJob("hosts_transferred", nil, []string{eHost.HardwareSerial}, 0)
s.runIntegrationsSchedule()
checkAssignProfileRequests(eHost.HardwareSerial, nil)
profUUID := profileAssignmentReqs[0].ProfileUUID
d := checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, nil, expectNoJobID)
require.NotZero(t, d.ResponseUpdatedAt)
failedAt := d.ResponseUpdatedAt
checkNoJobsPending()
// list hosts shows dep profile error
checkListHostDEPError(eHost.HardwareSerial, "On (automatic)", true)
// run the integrations schedule during the cooldown period
profileAssignmentReqs = []profileAssignmentReq{}
s.runIntegrationsSchedule()
require.Empty(t, profileAssignmentReqs) // no new request during cooldown
checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change
checkNoJobsPending()
// create a new team
var tmResp teamResponse
s.DoJSON("POST", "/api/latest/fleet/teams", &fleet.Team{
Name: t.Name() + "dummy",
Description: "desc dummy",
}, http.StatusOK, &tmResp)
require.NotZero(t, createTeamResp.Team.ID)
dummyTeam := tmResp.Team
s.Do("POST", "/api/v1/fleet/hosts/transfer",
addHostsToTeamRequest{TeamID: &dummyTeam.ID, HostIDs: []uint{eHost.ID}}, http.StatusOK)
checkPendingMacOSSetupAssistantJob("hosts_transferred", &dummyTeam.ID, []string{eHost.HardwareSerial}, 0)
s.runWorker()
// expect no assign profile request during cooldown
profileAssignmentReqs = []profileAssignmentReq{}
s.runIntegrationsSchedule()
require.Empty(t, profileAssignmentReqs) // screened for cooldown
checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change
checkNoJobsPending()
// cooldown hosts are screened from update profile jobs that would assign profiles
_, err = worker.QueueMacosSetupAssistantJob(ctx, s.ds, kitlog.NewNopLogger(), worker.MacosSetupAssistantUpdateProfile, &dummyTeam.ID, eHost.HardwareSerial)
require.NoError(t, err)
checkPendingMacOSSetupAssistantJob("update_profile", &dummyTeam.ID, []string{eHost.HardwareSerial}, 0)
s.runIntegrationsSchedule()
require.Empty(t, profileAssignmentReqs) // screened for cooldown
checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change
checkNoJobsPending()
// cooldown hosts are screened from delete profile jobs that would assign profiles
_, err = worker.QueueMacosSetupAssistantJob(ctx, s.ds, kitlog.NewNopLogger(), worker.MacosSetupAssistantProfileDeleted, &dummyTeam.ID, eHost.HardwareSerial)
require.NoError(t, err)
checkPendingMacOSSetupAssistantJob("profile_deleted", &dummyTeam.ID, []string{eHost.HardwareSerial}, 0)
s.runIntegrationsSchedule()
require.Empty(t, profileAssignmentReqs) // screened for cooldown
checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change
checkNoJobsPending()
// // TODO: Restore this test when FIXME on DeleteTeam is addressed
// s.Do("DELETE", fmt.Sprintf("/api/v1/fleet/teams/%d", dummyTeam.ID), nil, http.StatusOK)
// checkPendingMacOSSetupAssistantJob("team_deleted", nil, []string{eHost.HardwareSerial}, 0)
// s.runIntegrationsSchedule()
// require.Empty(t, profileAssignmentReqs) // screened for cooldown
// bySerial = checkHostDEPAssignProfileResponses([]string{eHost.HardwareSerial}, profUUID, fleet.DEPAssignProfileResponseFailed)
// d, ok = bySerial[eHost.HardwareSerial]
// require.True(t, ok)
// require.Equal(t, failedAt, d.ResponseUpdatedAt)
// require.Zero(t, d.RetryJobID) // cooling down so no retry job
// checkNoJobsPending()
// transfer back to no team, expect no assign profile request during cooldown
s.Do("POST", "/api/v1/fleet/hosts/transfer",
addHostsToTeamRequest{TeamID: nil, HostIDs: []uint{eHost.ID}}, http.StatusOK)
checkPendingMacOSSetupAssistantJob("hosts_transferred", nil, []string{eHost.HardwareSerial}, 0)
profileAssignmentReqs = []profileAssignmentReq{}
s.runIntegrationsSchedule()
require.Empty(t, profileAssignmentReqs) // screened for cooldown
checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change
checkNoJobsPending()
// simulate expired cooldown
failedAt = failedAt.Add(-2 * time.Hour)
setAssignProfileResponseUpdatedAt(eHost.HardwareSerial, failedAt)
profileAssignmentReqs = []profileAssignmentReq{}
s.runIntegrationsSchedule()
require.Empty(t, profileAssignmentReqs) // assign profile request will be made when the retry job is processed on the next worker run
d = checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, nil)
require.NotZero(t, d.RetryJobID) // retry job created
jobID := d.RetryJobID
checkPendingMacOSSetupAssistantJob("hosts_cooldown", nil, []string{eHost.HardwareSerial}, jobID)
// running the DEP schedule should not trigger a profile assignment request when the retry job is pending
profileAssignmentReqs = []profileAssignmentReq{}
s.runDEPSchedule()
require.Empty(t, profileAssignmentReqs) // assign profile request will be made when the retry job is processed on the next worker run
checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, &jobID) // no change
checkPendingMacOSSetupAssistantJob("hosts_cooldown", nil, []string{eHost.HardwareSerial}, jobID)
checkListHostDEPError(eHost.HardwareSerial, "On (automatic)", true)
// run the integration schedule and expect success
expectAssignProfileResponseFailed = ""
profileAssignmentReqs = []profileAssignmentReq{}
s.runIntegrationsSchedule()
checkAssignProfileRequests(eHost.HardwareSerial, &profUUID)
d = checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseSuccess, nil, expectNoJobID) // retry job cleared
require.True(t, d.ResponseUpdatedAt.After(failedAt))
succeededAt := d.ResponseUpdatedAt
checkNoJobsPending()
checkListHostDEPError(eHost.HardwareSerial, "On (automatic)", false)
// run the integrations schedule and expect no changes
profileAssignmentReqs = []profileAssignmentReq{}
s.runIntegrationsSchedule()
require.Empty(t, profileAssignmentReqs)
checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseSuccess, &succeededAt, expectNoJobID) // no change
checkNoJobsPending()
// ingest new device via DEP but the profile assignment fails
serial := uuid.NewString()
devices = []godep.Device{
{SerialNumber: serial, Model: "MacBook Pro", OS: "osx", OpType: "added"},
}
expectAssignProfileResponseFailed = serial
profileAssignmentReqs = []profileAssignmentReq{}
s.runDEPSchedule()
checkAssignProfileRequests(serial, nil)
profUUID = profileAssignmentReqs[0].ProfileUUID
d = checkHostCooldown(serial, profUUID, fleet.DEPAssignProfileResponseFailed, nil, expectNoJobID)
require.NotZero(t, d.ResponseUpdatedAt)
failedAt = d.ResponseUpdatedAt
checkNoJobsPending()
h := checkListHostDEPError(serial, "Pending", true) // list hosts shows device pending and dep profile error
// transfer to team, no profile assignment request is made during the cooldown period
profileAssignmentReqs = []profileAssignmentReq{}
s.Do("POST", "/api/v1/fleet/hosts/transfer",
addHostsToTeamRequest{TeamID: &team.ID, HostIDs: []uint{h.ID}}, http.StatusOK)
checkPendingMacOSSetupAssistantJob("hosts_transferred", &team.ID, []string{serial}, 0)
s.runIntegrationsSchedule()
require.Empty(t, profileAssignmentReqs) // screened by cooldown
checkHostCooldown(serial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change
checkNoJobsPending()
// run the integrations schedule and expect no changes
profileAssignmentReqs = []profileAssignmentReq{}
s.runIntegrationsSchedule()
require.Empty(t, profileAssignmentReqs)
checkHostCooldown(serial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change
checkNoJobsPending()
// simulate expired cooldown
failedAt = failedAt.Add(-2 * time.Hour)
setAssignProfileResponseUpdatedAt(serial, failedAt)
profileAssignmentReqs = []profileAssignmentReq{}
s.runIntegrationsSchedule()
require.Empty(t, profileAssignmentReqs) // assign profile request will be made when the retry job is processed on the next worker run
d = checkHostCooldown(serial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, nil)
require.NotZero(t, d.RetryJobID) // retry job created
jobID = d.RetryJobID
checkPendingMacOSSetupAssistantJob("hosts_cooldown", &team.ID, []string{serial}, jobID)
// run the inregration schedule and expect success
expectAssignProfileResponseFailed = ""
profileAssignmentReqs = []profileAssignmentReq{}
s.runIntegrationsSchedule()
checkAssignProfileRequests(serial, nil)
require.NotEqual(t, profUUID, profileAssignmentReqs[0].ProfileUUID) // retry job will use the current team profile instead
profUUID = profileAssignmentReqs[0].ProfileUUID
d = checkHostCooldown(serial, profUUID, fleet.DEPAssignProfileResponseSuccess, nil, expectNoJobID) // retry job cleared
require.True(t, d.ResponseUpdatedAt.After(failedAt))
checkNoJobsPending()
// list hosts shows pending (because MDM detail query hasn't been reported) but dep profile
// error has been cleared
checkListHostDEPError(serial, "Pending", false)
// ingest another device via DEP but the profile assignment is not accessible
serial = uuid.NewString()
devices = []godep.Device{
{SerialNumber: serial, Model: "MacBook Pro", OS: "osx", OpType: "added"},
}
expectAssignProfileResponseNotAccessible = serial
profileAssignmentReqs = []profileAssignmentReq{}
s.runDEPSchedule()
require.Len(t, profileAssignmentReqs, 2) // FIXME: When new device is added in ABM, we see two profile assign requests when device is not accessible: first during the "fetch" phase, then during the "sync" phase
expectProfileUUID := ""
for _, req := range profileAssignmentReqs {
require.Len(t, req.Devices, 1)
require.Equal(t, serial, req.Devices[0])
if expectProfileUUID == "" {
expectProfileUUID = req.ProfileUUID
} else {
require.Equal(t, expectProfileUUID, req.ProfileUUID)
}
d := checkHostCooldown(serial, req.ProfileUUID, fleet.DEPAssignProfileResponseNotAccessible, nil, expectNoJobID) // not accessible responses aren't retried
require.NotZero(t, d.ResponseUpdatedAt)
failedAt = d.ResponseUpdatedAt
}
// list hosts shows device pending and no dep profile error for not accessible responses
checkListHostDEPError(serial, "Pending", false)
// no retry job for not accessible responses even if cooldown expires
failedAt = failedAt.Add(-2 * time.Hour)
setAssignProfileResponseUpdatedAt(serial, failedAt)
profileAssignmentReqs = []profileAssignmentReq{}
s.runIntegrationsSchedule()
require.Empty(t, profileAssignmentReqs)
checkHostCooldown(serial, expectProfileUUID, fleet.DEPAssignProfileResponseNotAccessible, &failedAt, expectNoJobID) // no change
checkNoJobsPending()
// run with devices that already have valid and invalid profiles
// assigned, we shouldn't re-assign the valid ones.
devices = []godep.Device{
{SerialNumber: uuid.NewString(), Model: "MacBook Pro", OS: "osx", OpType: "added", ProfileUUID: defaultProfileUUID}, // matches existing profile, but will be assigned since it is "added"
{SerialNumber: uuid.NewString(), Model: "MacBook Mini", OS: "osx", OpType: "modified", ProfileUUID: defaultProfileUUID}, // matches existing profile
{SerialNumber: uuid.NewString(), Model: "MacBook Pro", OS: "osx", OpType: "added", ProfileUUID: "bar"}, // doesn't match an existing profile
{SerialNumber: uuid.NewString(), Model: "MacBook Mini", OS: "osx", OpType: "modified", ProfileUUID: "foo"}, // doesn't match an existing profile
{SerialNumber: addedSerial, Model: "MacBook Pro", OS: "osx", OpType: "added", ProfileUUID: defaultProfileUUID}, // matches existing profile, but will be assigned since it is "added"
{SerialNumber: serial, Model: "MacBook Mini", OS: "osx", OpType: "modified", ProfileUUID: defaultProfileUUID}, // matches existing profile
}
expectAssignProfileResponseNotAccessible = ""
profileAssignmentReqs = []profileAssignmentReq{}
s.runDEPSchedule()
require.NotEmpty(t, profileAssignmentReqs)
require.Len(t, profileAssignmentReqs, 2)
require.Len(t, profileAssignmentReqs[0].Devices, 1) // The first device response only returns the first device
assert.ElementsMatch(t, []string{devices[0].SerialNumber}, profileAssignmentReqs[0].Devices)
checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess)
require.Len(t, profileAssignmentReqs[1].Devices, 4) // All of them
assert.ElementsMatch(t, []string{devices[0].SerialNumber, devices[2].SerialNumber, devices[3].SerialNumber, devices[4].SerialNumber}, profileAssignmentReqs[1].Devices)
checkHostDEPAssignProfileResponses(profileAssignmentReqs[1].Devices, profileAssignmentReqs[1].ProfileUUID, fleet.DEPAssignProfileResponseSuccess)
// run with only a device that already has the right profile, no errors and no assignments
devices = []godep.Device{
{SerialNumber: uuid.NewString(), Model: "MacBook Pro", OS: "osx", OpType: "modified", ProfileUUID: defaultProfileUUID}, // matches existing profile
}
profileAssignmentReqs = []profileAssignmentReq{}
s.runDEPSchedule()
require.Empty(t, profileAssignmentReqs)
}
func (s *integrationMDMTestSuite) TestDEPProfileAssignmentWithMultipleABMs() {
t := s.T()
ctx := context.Background()
type hostDEPRow struct {
HostID uint `db:"host_id"`
ProfileUUID string `db:"profile_uuid"`
AssignProfileResponse string `db:"assign_profile_response"`
ResponseUpdatedAt time.Time `db:"response_updated_at"`
RetryJobID uint `db:"retry_job_id"`
}
checkHostDEPAssignProfileResponses := func(deviceSerials []string, expectedProfileUUID string,
expectedStatus fleet.DEPAssignProfileResponseStatus,
) map[string]hostDEPRow {
bySerial := make(map[string]hostDEPRow, len(deviceSerials))
for _, deviceSerial := range deviceSerials {
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
var dest hostDEPRow
err := sqlx.GetContext(ctx, q, &dest,
"SELECT host_id, assign_profile_response, profile_uuid, response_updated_at, retry_job_id FROM host_dep_assignments WHERE profile_uuid = ? AND host_id = (SELECT id FROM hosts WHERE hardware_serial = ?)",
expectedProfileUUID, deviceSerial)
require.NoError(t, err)
require.Equal(t, string(expectedStatus), dest.AssignProfileResponse)
bySerial[deviceSerial] = dest
return nil
})
}
return bySerial
}
devices := []godep.Device{
{SerialNumber: uuid.New().String(), Model: "MacBook Pro M1", OS: "osx", OpType: "added", OpDate: time.Now()},
{SerialNumber: uuid.New().String(), Model: "MacBook Mini M1", OS: "osx", OpType: "added", OpDate: time.Now()},
}
defaultOrgDevices := []godep.Device{
{SerialNumber: uuid.New().String(), Model: "MacBook Mini M2", OS: "osx", OpType: "added", OpDate: time.Now()},
{SerialNumber: uuid.New().String(), Model: "MacBook Pro M2", OS: "osx", OpType: "added", OpDate: time.Now()},
}
// set release device manually to true so there is no job enqueued at a later
// time to release the device (this is not what this test is about)
s.Do("PATCH", "/api/latest/fleet/setup_experience", json.RawMessage(jsonMustMarshal(t, map[string]any{
"enable_release_device_manually": true,
})), http.StatusNoContent)
// set up multiple ABM tokens with different org names
defaultOrgName := "default_" + t.Name()
s.enableABM(defaultOrgName)
tmOrgName := t.Name()
s.enableABM(tmOrgName)
// create a new team
tm, err := s.ds.NewTeam(context.Background(), &fleet.Team{
Name: tmOrgName,
Description: "desc",
})
require.NoError(t, err)
// set the default bm assignment for that token to that team
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
}]
}
}`, tmOrgName, tm.Name, tm.Name, tm.Name)), http.StatusOK, &acResp)
t.Cleanup(func() {
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": {
"apple_business_manager": []
}
}`), http.StatusOK, &acResp)
})
tmProf := `{"profile_name": "Team Profile"}`
var createResp createMDMAppleSetupAssistantResponse
s.DoJSON("POST", "/api/latest/fleet/enrollment_profiles/automatic", createMDMAppleSetupAssistantRequest{
TeamID: &tm.ID,
Name: tmOrgName,
EnrollmentProfile: json.RawMessage(tmProf),
}, http.StatusOK, &createResp)
assert.Equal(t, tm.ID, *createResp.TeamID)
var teamProfileUUIDs []string
var defaultProfileUUIDs []string
s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
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":
profileUUID := uuid.NewString()
teamProfileUUIDs = append(teamProfileUUIDs, profileUUID)
err := encoder.Encode(godep.ProfileResponse{ProfileUUID: profileUUID})
require.NoError(t, err)
case "/server/devices":
// This endpoint is used to get an initial list of
// devices, return a single device
err := encoder.Encode(godep.DeviceResponse{Devices: devices[:1]})
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: devices, 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
assert.Contains(t, teamProfileUUIDs, resp.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(`{}`))
}
}))
s.mockDEPResponse(defaultOrgName, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
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":
profileUUID := uuid.NewString()
defaultProfileUUIDs = append(defaultProfileUUIDs, profileUUID)
err := encoder.Encode(godep.ProfileResponse{ProfileUUID: profileUUID})
require.NoError(t, err)
case "/server/devices":
// This endpoint is used to get an initial list of
// devices, return a single device
err := encoder.Encode(godep.DeviceResponse{Devices: defaultOrgDevices[:1]})
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: defaultOrgDevices, 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
assert.Contains(t, defaultProfileUUIDs, resp.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(`{}`))
}
}))
// query all hosts
listHostsRes := listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
require.Empty(t, listHostsRes.Hosts)
// trigger a profile sync
s.runDEPSchedule()
// all hosts should be returned from the hosts endpoint
listHostsRes = listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
numHosts := len(devices) + len(defaultOrgDevices)
assert.Len(t, listHostsRes.Hosts, numHosts)
defaultSerials := []string{defaultOrgDevices[0].SerialNumber, defaultOrgDevices[1].SerialNumber}
teamSerials := []string{devices[0].SerialNumber, devices[1].SerialNumber}
for _, host := range listHostsRes.Hosts {
switch {
case slices.Contains(defaultSerials, host.HardwareSerial):
assert.Nil(t, host.TeamID)
case slices.Contains(teamSerials, host.HardwareSerial):
assert.NotNil(t, host.TeamID)
default:
t.Errorf("unexpected host serial %s", host.HardwareSerial)
}
}
require.GreaterOrEqual(t, len(defaultProfileUUIDs), 1)
require.Len(t, teamProfileUUIDs, 2)
checkHostDEPAssignProfileResponses(defaultSerials, defaultProfileUUIDs[len(defaultProfileUUIDs)-1],
fleet.DEPAssignProfileResponseSuccess)
checkHostDEPAssignProfileResponses(teamSerials, teamProfileUUIDs[len(teamProfileUUIDs)-1], fleet.DEPAssignProfileResponseSuccess)
// Delete the devices in one org, and add them to the other (x2)
devices = []godep.Device{
{SerialNumber: devices[0].SerialNumber, Model: "MacBook Pro M1", OS: "osx", OpType: "added", OpDate: time.Now()},
{SerialNumber: devices[1].SerialNumber, Model: "MacBook Mini M1", OS: "osx", OpType: "deleted", OpDate: time.Now()},
{
SerialNumber: defaultOrgDevices[1].SerialNumber, Model: "MacBook Pro M2", OS: "osx", OpType: "added",
OpDate: time.Now().Add(time.Microsecond),
},
}
defaultOrgDevices = []godep.Device{
{SerialNumber: defaultOrgDevices[0].SerialNumber, Model: "MacBook Mini M2", OS: "osx", OpType: "added", OpDate: time.Now()},
{SerialNumber: defaultOrgDevices[1].SerialNumber, Model: "MacBook Pro M2", OS: "osx", OpType: "deleted", OpDate: time.Now()},
{
SerialNumber: devices[1].SerialNumber, Model: "MacBook Mini M1", OS: "osx", OpType: "added",
OpDate: time.Now().Add(time.Microsecond),
},
}
// trigger a profile sync
s.runDEPSchedule()
// all hosts should be returned from the hosts endpoint; the 2 deleted and re-added hosts should switch teams and profiles
listHostsRes = listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
assert.Len(t, listHostsRes.Hosts, numHosts)
defaultSerials = []string{defaultOrgDevices[0].SerialNumber, defaultOrgDevices[2].SerialNumber}
teamSerials = []string{devices[0].SerialNumber, devices[2].SerialNumber}
for _, host := range listHostsRes.Hosts {
switch {
case slices.Contains(defaultSerials, host.HardwareSerial):
assert.Nil(t, host.TeamID)
case slices.Contains(teamSerials, host.HardwareSerial):
assert.NotNil(t, host.TeamID)
default:
t.Errorf("unexpected host serial %s", host.HardwareSerial)
}
}
checkHostDEPAssignProfileResponses(defaultSerials, defaultProfileUUIDs[len(defaultProfileUUIDs)-1],
fleet.DEPAssignProfileResponseSuccess)
checkHostDEPAssignProfileResponses(teamSerials, teamProfileUUIDs[len(teamProfileUUIDs)-1], fleet.DEPAssignProfileResponseSuccess)
// Delete the devices
devices = []godep.Device{
{SerialNumber: devices[0].SerialNumber, Model: "MacBook Pro M1", OS: "osx", OpType: "modified", OpDate: time.Now()},
{
SerialNumber: devices[2].SerialNumber, Model: "MacBook Pro M2", OS: "osx", OpType: "deleted",
OpDate: time.Now().Add(time.Microsecond),
},
}
defaultOrgDevices = []godep.Device{
{SerialNumber: defaultOrgDevices[0].SerialNumber, Model: "MacBook Mini M2", OS: "osx", OpType: "modified", OpDate: time.Now()},
{
SerialNumber: defaultOrgDevices[2].SerialNumber, Model: "MacBook Mini M1", OS: "osx", OpType: "deleted",
OpDate: time.Now().Add(time.Microsecond),
},
}
// trigger a profile sync
s.runDEPSchedule()
// 2 hosts should be gone
listHostsRes = listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
assert.Len(t, listHostsRes.Hosts, numHosts-2)
defaultSerials = []string{defaultOrgDevices[0].SerialNumber}
teamSerials = []string{devices[0].SerialNumber}
for _, host := range listHostsRes.Hosts {
switch {
case slices.Contains(defaultSerials, host.HardwareSerial):
assert.Nil(t, host.TeamID)
case slices.Contains(teamSerials, host.HardwareSerial):
assert.NotNil(t, host.TeamID)
default:
t.Errorf("unexpected host serial %s", host.HardwareSerial)
}
}
checkHostDEPAssignProfileResponses(defaultSerials, defaultProfileUUIDs[len(defaultProfileUUIDs)-1],
fleet.DEPAssignProfileResponseSuccess)
checkHostDEPAssignProfileResponses(teamSerials, teamProfileUUIDs[len(teamProfileUUIDs)-1], fleet.DEPAssignProfileResponseSuccess)
}
func (s *integrationMDMTestSuite) TestDeprecatedDefaultAppleBMTeam() {
t := s.T()
s.enableABM(t.Name())
tm, err := s.ds.NewTeam(context.Background(), &fleet.Team{
Name: t.Name(),
Description: "desc",
})
require.NoError(s.T(), err)
var acResp appConfigResponse
defer func() {
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": {
"apple_bm_default_team": ""
}
}`), http.StatusOK, &acResp)
require.Empty(t, acResp.MDM.DeprecatedAppleBMDefaultTeam)
}()
// try to set an invalid team name
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": {
"apple_bm_default_team": "xyz"
}
}`), http.StatusUnprocessableEntity, &acResp)
// get the appconfig, nothing changed
acResp = appConfigResponse{}
s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp)
require.Empty(t, acResp.MDM.DeprecatedAppleBMDefaultTeam)
// set to a valid team name
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(fmt.Sprintf(`{
"mdm": {
"apple_bm_default_team": %q
}
}`, tm.Name)), http.StatusOK, &acResp)
require.Equal(t, tm.Name, acResp.MDM.DeprecatedAppleBMDefaultTeam)
// get the appconfig, set to that team name
acResp = appConfigResponse{}
s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp)
require.Equal(t, tm.Name, acResp.MDM.DeprecatedAppleBMDefaultTeam)
}
func (s *integrationMDMTestSuite) TestReenrollingADEDeviceAfterRemovingItFromABM() {
t := s.T()
s.enableABM(t.Name())
ctx := context.Background()
checkPostEnrollmentCommands := func(mdmDevice *mdmtest.TestAppleMDMClient, shouldReceive bool) {
// run the worker to process the DEP enroll request
s.runWorker()
// run the worker to assign configuration profiles
s.awaitTriggerProfileSchedule(t)
var fleetdCmd, installProfileCmd *micromdm.CommandPayload
cmd, err := mdmDevice.Idle()
require.NoError(t, err)
for cmd != nil {
var fullCmd micromdm.CommandPayload
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
if fullCmd.Command.RequestType == "InstallEnterpriseApplication" &&
fullCmd.Command.InstallEnterpriseApplication.ManifestURL != nil &&
strings.Contains(*fullCmd.Command.InstallEnterpriseApplication.ManifestURL, fleetdbase.GetPKGManifestURL()) {
fleetdCmd = &fullCmd
} else if cmd.Command.RequestType == "InstallProfile" {
installProfileCmd = &fullCmd
}
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
}
if shouldReceive {
// received request to install fleetd
require.NotNil(t, fleetdCmd, "host didn't get a command to install fleetd")
require.NotNil(t, fleetdCmd.Command, "host didn't get a command to install fleetd")
// received request to install the global configuration profile
require.NotNil(t, installProfileCmd, "host didn't get a command to install profiles")
require.NotNil(t, installProfileCmd.Command, "host didn't get a command to install profiles")
} else {
require.Nil(t, fleetdCmd, "host got a command to install fleetd")
require.Nil(t, installProfileCmd, "host got a command to install profiles")
}
}
devices := []godep.Device{
{SerialNumber: uuid.New().String(), Model: "MacBook Pro", OS: "osx", OpType: "added"},
}
profileAssignmentReqs := []profileAssignmentReq{}
type hostDEPRow struct {
HostID uint `db:"host_id"`
ProfileUUID string `db:"profile_uuid"`
AssignProfileResponse string `db:"assign_profile_response"`
ResponseUpdatedAt time.Time `db:"response_updated_at"`
RetryJobID uint `db:"retry_job_id"`
DeletedAt *time.Time `db:"deleted_at"`
}
checkHostDEPAssignProfileResponses := func(deviceSerials []string, expectedProfileUUID string, expectedStatus fleet.DEPAssignProfileResponseStatus) map[string]hostDEPRow {
bySerial := make(map[string]hostDEPRow, len(deviceSerials))
for _, deviceSerial := range deviceSerials {
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
var dest hostDEPRow
err := sqlx.GetContext(ctx, q, &dest, "SELECT host_id, assign_profile_response, profile_uuid, response_updated_at, retry_job_id, deleted_at FROM host_dep_assignments WHERE profile_uuid = ? AND host_id = (SELECT id FROM hosts WHERE hardware_serial = ?)", expectedProfileUUID, deviceSerial)
require.NoError(t, err)
require.Equal(t, string(expectedStatus), dest.AssignProfileResponse)
bySerial[deviceSerial] = dest
return nil
})
}
return bySerial
}
s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
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":
// This endpoint is used to get an initial list of
// devices, return a single device
err := encoder.Encode(godep.DeviceResponse{Devices: devices[:1]})
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: devices, 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))
profileAssignmentReqs = append(profileAssignmentReqs, 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(`{}`))
}
}))
s.pushProvider.PushFunc = func(_ context.Context, pushes []*mdm.Push) (map[string]*push.Response, error) {
return map[string]*push.Response{}, nil
}
// Enroll the host via ADE
depURLToken := loadEnrollmentProfileDEPToken(t, s.ds)
mdmDevice := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, depURLToken)
mdmDevice.SerialNumber = devices[0].SerialNumber
err := mdmDevice.Enroll()
require.NoError(t, err)
// Simulate an osquery enrollment too
// set an enroll secret
var applyResp applyEnrollSecretSpecResponse
s.DoJSON("POST", "/api/latest/fleet/spec/enroll_secret", applyEnrollSecretSpecRequest{
Spec: &fleet.EnrollSecretSpec{
Secrets: []*fleet.EnrollSecret{{Secret: t.Name()}},
},
}, http.StatusOK, &applyResp)
// simulate a matching host enrolling via osquery
j, err := json.Marshal(&contract.EnrollOsqueryAgentRequest{
EnrollSecret: t.Name(),
HostIdentifier: mdmDevice.UUID,
})
require.NoError(t, err)
var enrollResp contract.EnrollOsqueryAgentResponse
hres := s.DoRawNoAuth("POST", "/api/osquery/enroll", j, http.StatusOK)
defer hres.Body.Close()
require.NoError(t, json.NewDecoder(hres.Body).Decode(&enrollResp))
require.NotEmpty(t, enrollResp.NodeKey)
listHostsRes := listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
require.Len(t, listHostsRes.Hosts, 1)
h := listHostsRes.Hosts[0]
s.runDEPSchedule()
// make sure the host gets post enrollment requests
checkPostEnrollmentCommands(mdmDevice, true)
var hostResp getHostResponse
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d", h.ID), getHostRequest{}, http.StatusOK, &hostResp)
// 1 profile with fleetd configuration + 1 root CA config
require.Len(t, *hostResp.Host.MDM.Profiles, 2)
// Turn MDM off in the host
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/mdm", h.ID), nil, http.StatusNoContent)
// profiles are removed and the host is no longer enrolled
hostResp = getHostResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d", h.ID), getHostRequest{}, http.StatusOK, &hostResp)
require.Nil(t, hostResp.Host.MDM.Profiles)
require.Equal(t, "", hostResp.Host.MDM.Name)
err = mdmDevice.Checkout()
require.NoError(t, err)
// Simulate the device getting unassigned from Fleet in ABM
devices = []godep.Device{
{SerialNumber: mdmDevice.SerialNumber, Model: "MacBook Pro", OS: "osx", OpType: "deleted", OpDate: time.Now()},
}
s.runDEPSchedule()
a := checkHostDEPAssignProfileResponses([]string{mdmDevice.SerialNumber}, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess)
require.NotZero(t, a[mdmDevice.SerialNumber].DeletedAt)
// Now we add the device back into ABM
profileAssignmentReqs = []profileAssignmentReq{}
devices = []godep.Device{
// In https://github.com/fleetdm/fleet/issues/23200, we saw a profileUUID being sent back on
// the godep.Device in the response from ABM. We're not 100% sure why, but the fact that
// this field is set was the source of the bug, which is why we're including it here.
{SerialNumber: mdmDevice.SerialNumber, Model: "MacBook Pro", OS: "osx", OpType: "added", OpDate: time.Now(), ProfileUUID: a[mdmDevice.SerialNumber].ProfileUUID},
}
s.runDEPSchedule()
a = checkHostDEPAssignProfileResponses([]string{mdmDevice.SerialNumber}, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess)
require.Nil(t, a[mdmDevice.SerialNumber].DeletedAt)
err = mdmDevice.Enroll()
require.NoError(t, err)
// make sure the host gets post enrollment requests
checkPostEnrollmentCommands(mdmDevice, true)
}
func (s *integrationMDMTestSuite) TestEnforceMiniumOSVersion() {
t := s.T()
s.enableABM(t.Name())
latestMacOSVersion := "14.6.1" // this is the latest version in our test data (see ../mdm/apple/gdmf/testdata/gdmf.json)
latestMacOSBuild := "23G93" // this is the latest version in our test data (see ../mdm/apple/gdmf/testdata/gdmf.json)
deadline := "2023-12-31"
scepChallenge := "scepcha/><llenge"
scepURL := s.server.URL + "/mdm/apple/scep"
mdmURL := s.server.URL + "/mdm/apple/mdm"
// for our tests, we'll crete two devices: devices[0] will be enrolled with no team and
// devices[1] will be enrolled with a team (created later in this test)
devices := []godep.Device{
{SerialNumber: uuid.New().String(), Model: "MacBook Pro", OS: "osx", OpType: "added"},
{SerialNumber: uuid.New().String(), Model: "MacBook Pro", OS: "osx", OpType: "added"},
}
s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
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":
// This endpoint is used to get an initial list of
// devices, return a single device
err := encoder.Encode(godep.DeviceResponse{Devices: devices})
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: devices, 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(`{}`))
}
}))
s.runDEPSchedule()
// confirm that the devices were created
listHostsRes := listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
require.Len(t, listHostsRes.Hosts, 2)
bySerial := make(map[string]*fleet.Host, len(devices))
for _, h := range listHostsRes.Hosts {
bySerial[h.HardwareSerial] = h.Host
}
// create a team and add the second device to it
teamHost := bySerial[devices[1].SerialNumber]
require.NotNil(t, teamHost)
team := &fleet.Team{
Name: t.Name() + "team1",
Description: "desc team1",
}
var createTeamResp teamResponse
s.DoJSON("POST", "/api/latest/fleet/teams", team, http.StatusOK, &createTeamResp)
require.NotZero(t, createTeamResp.Team.ID)
team = createTeamResp.Team
require.NoError(t, s.ds.AddHostsToTeam(context.Background(), fleet.NewAddHostsToTeamParams(&team.ID, []uint{teamHost.ID})))
// this helper function calls the /enroll endpoint with the supplied machineInfo (from the test
// case) and checks for the expected response
checkMDMEnrollEndpoint := func(t *testing.T, machineInfo *fleet.MDMAppleMachineInfo, expectEnrollInfo *mdmtest.AppleEnrollInfo, expectSoftwareUpdate *fleet.MDMAppleSoftwareUpdateRequiredDetails, expectErr string, ssoDisabled bool) error {
var di string
if machineInfo != nil {
encoded, err := mdmtest.EncodeDeviceInfo(*machineInfo)
require.NoError(t, err)
di = encoded
}
path := s.server.URL + apple_mdm.EnrollPath + "?token=" + loadEnrollmentProfileDEPToken(t, s.ds)
if !ssoDisabled && di != "" {
// if SSO is enabled, we expect the deviceinfo in the URL
path += "&deviceinfo=" + di
}
request, err := http.NewRequest("GET", path, nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
if ssoDisabled && di != "" {
// if SSO is disabled, we expect the deviceinfo in the header
request.Header.Set("x-apple-aspen-deviceinfo", di)
}
// nolint:gosec // this client is used for testing only
cc := fleethttp.NewClient(fleethttp.WithTLSClientConfig(&tls.Config{
InsecureSkipVerify: true,
}))
response, err := cc.Do(request)
if err != nil {
return fmt.Errorf("send request: %w", err)
}
defer response.Body.Close()
if expectErr != "" {
assert.Equal(t, http.StatusBadRequest, response.StatusCode)
body, err := io.ReadAll(response.Body)
if err != nil {
return fmt.Errorf("read body: %w", err)
}
require.Contains(t, string(body), expectErr)
return nil
}
switch {
case expectEnrollInfo != nil:
require.Equal(t, http.StatusOK, response.StatusCode)
body, err := io.ReadAll(response.Body)
if err != nil {
return fmt.Errorf("read body: %w", err)
}
rawProfile := body
if !bytes.HasPrefix(rawProfile, []byte("<?xml")) {
p7, err := pkcs7.Parse(body)
if err != nil {
return fmt.Errorf("enrollment profile is not XML nor PKCS7 parseable: %w", err)
}
err = p7.Verify()
if err != nil {
return err
}
rawProfile = p7.Content
}
enrollInfo, err := mdmtest.ParseEnrollmentProfile(rawProfile)
if err != nil {
return fmt.Errorf("parse enrollment profile: %w", err)
}
require.NotNil(t, enrollInfo)
require.Equal(t, expectEnrollInfo, enrollInfo)
return nil
case expectSoftwareUpdate != nil:
require.Equal(t, http.StatusForbidden, response.StatusCode)
body, err := io.ReadAll(response.Body)
if err != nil {
return fmt.Errorf("read body: %w", err)
}
var sur fleet.MDMAppleSoftwareUpdateRequired
require.NoError(t, json.Unmarshal(body, &sur))
require.NotNil(t, sur)
require.Equal(t, fleet.MDMAppleSoftwareUpdateRequiredCode, sur.Code)
require.Equal(t, *expectSoftwareUpdate, sur.Details)
return nil
case machineInfo == nil:
// we always expect a 400 error from this endpoint if deviceinfo is missing, at this
// stage it should either be in the URL (if SSO is enabled) or in the header
// (if SSO is disabled)
require.Equal(t, http.StatusBadRequest, response.StatusCode)
body, err := io.ReadAll(response.Body)
if err != nil {
return fmt.Errorf("read body: %w", err)
}
require.Contains(t, string(body), "missing deviceinfo")
return nil
default:
return fmt.Errorf("request error: %d, %s", response.StatusCode, response.Status)
}
}
// this helper function calls the /mdm/sso endpoint with the supplied machineInfo (from the test
// case) and checks for the expected response
checkMDMSSOEndpoint := func(t *testing.T, machineInfo *fleet.MDMAppleMachineInfo, expectSoftwareUpdate *fleet.MDMAppleSoftwareUpdateRequiredDetails, expectErr string) error {
request, err := http.NewRequest("GET", s.server.URL+"/mdm/sso", nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
if machineInfo != nil {
di, err := mdmtest.EncodeDeviceInfo(*machineInfo)
require.NoError(t, err)
request.Header.Set("x-apple-aspen-deviceinfo", di)
}
// nolint:gosec // this client is used for testing only
cc := fleethttp.NewClient(fleethttp.WithTLSClientConfig(&tls.Config{
InsecureSkipVerify: true,
}))
response, err := cc.Do(request)
if err != nil {
return fmt.Errorf("send request: %w", err)
}
defer response.Body.Close()
if expectErr != "" {
assert.Equal(t, http.StatusBadRequest, response.StatusCode)
body, err := io.ReadAll(response.Body)
if err != nil {
return fmt.Errorf("read body: %w", err)
}
assert.Contains(t, string(body), expectErr)
return nil
}
switch {
case expectSoftwareUpdate != nil:
require.Equal(t, http.StatusForbidden, response.StatusCode)
body, err := io.ReadAll(response.Body)
if err != nil {
return fmt.Errorf("read body: %w", err)
}
var sur fleet.MDMAppleSoftwareUpdateRequired
require.NoError(t, json.Unmarshal(body, &sur))
require.NotNil(t, sur)
require.Equal(t, fleet.MDMAppleSoftwareUpdateRequiredCode, sur.Code)
require.Equal(t, *expectSoftwareUpdate, sur.Details)
return nil
case machineInfo == nil:
// if deviceinfo is missing, the request still procceds to the frontend sso app, but
// it will will eventually fail when the frontend attempts the sso callback without the
// required deviceinfo
require.Equal(t, http.StatusOK, response.StatusCode)
return nil
case response.StatusCode == http.StatusOK:
// this is the expected happy path based on the test server setup (note that the full
// SSO callback flow is not being tested here, just the OS enforcement that is tied to
// the `GET /mdm/sso` initial request)
// https://github.com/fleetdm/fleet/blob/e62956924bbe041a1e75be2b0b7c6d1dd235a09d/server/service/testing_utils.go#L420-L421
return nil
default:
return fmt.Errorf("request error: %d, %s", response.StatusCode, response.Status)
}
}
// this helper function sets the minimum OS version for the team or no team
setMinOSVersion := func(minVersion string, deadline string, teamID *uint) {
raw := json.RawMessage(fmt.Sprintf(`{ "mdm": { "macos_updates": { "minimum_version": "%s", "deadline": "%s" } } }`, minVersion, deadline))
if teamID == nil {
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", raw, http.StatusOK, &acResp)
assert.NotNil(t, acResp.MDM.MacOSUpdates)
} else {
tcResp := teamResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", *teamID), raw, http.StatusOK, &tcResp)
}
}
// this helper function sets the enable end user authentication for the team or no team
setEnableEndUserAuth := func(enable bool, teamID *uint) {
raw := json.RawMessage(fmt.Sprintf(`{ "mdm": { "macos_setup": { "enable_end_user_authentication": %v } } }`, enable))
if teamID == nil {
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", raw, http.StatusOK, &acResp)
} else {
tcResp := teamResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", *teamID), raw, http.StatusOK, &tcResp)
}
}
// configure idp settings
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": {
"end_user_authentication": { "entity_id": "https://example.com", "idp_name": "example-idp", "metadata_url": "https://idp.example.com/metadata" }
}
}`), http.StatusOK, &acResp)
t.Cleanup(func() {
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": {
"macos_updates": { "minimum_version": null, "deadline": null },
"macos_setup": { "enable_end_user_authentication": false },
"end_user_authentication": { "entity_id": "", "idp_name": "", "metadata_url": "", "metadata": "" }
}
}`), http.StatusOK, &acResp)
})
testCases := []struct {
name string
machineInfo *fleet.MDMAppleMachineInfo
updateRequired *fleet.MDMAppleSoftwareUpdateRequiredDetails
// err is reserved for future test cases; currently we aren't expecting errors with this endpoint
// because product specs say to allow enrollment to proceed without software update so this
// is here so we can be explicit about those expectations and allow for future test cases
// that may need to check for errors
err string
}{
{
name: "device above latest",
machineInfo: &fleet.MDMAppleMachineInfo{
MDMCanRequestSoftwareUpdate: true,
Product: "Mac15,7",
OSVersion: "14.6.2",
SupplementalBuildVersion: "IRRELEVANT",
SoftwareUpdateDeviceID: "J516sAP",
},
updateRequired: nil,
},
{
name: "device equal to latest",
machineInfo: &fleet.MDMAppleMachineInfo{
MDMCanRequestSoftwareUpdate: true,
Product: "Mac15,7",
OSVersion: latestMacOSVersion,
SupplementalBuildVersion: "IRRELEVANT",
SoftwareUpdateDeviceID: "J516sAP",
},
updateRequired: nil,
},
{
name: "device below latest",
machineInfo: &fleet.MDMAppleMachineInfo{
MDMCanRequestSoftwareUpdate: true,
Product: "Mac15,7",
OSVersion: "14.4",
SupplementalBuildVersion: "IRRELEVANT",
SoftwareUpdateDeviceID: "J516sAP",
},
updateRequired: &fleet.MDMAppleSoftwareUpdateRequiredDetails{
OSVersion: latestMacOSVersion,
BuildVersion: latestMacOSBuild,
},
},
{
name: "device below latest but MDM cannot request software update",
machineInfo: &fleet.MDMAppleMachineInfo{
MDMCanRequestSoftwareUpdate: false,
Product: "Mac15,7",
OSVersion: "14.4",
SupplementalBuildVersion: "IRRELEVANT",
SoftwareUpdateDeviceID: "J516sAP",
},
updateRequired: nil,
},
{
name: "no match for software update device ID",
machineInfo: &fleet.MDMAppleMachineInfo{
MDMCanRequestSoftwareUpdate: true,
Product: "Mac15,7",
OSVersion: "14.4",
SupplementalBuildVersion: "IRRELEVANT",
SoftwareUpdateDeviceID: "INVALID",
},
updateRequired: nil,
err: "", // no error, allow enrollment to proceed without software update
},
{
name: "no machine info",
machineInfo: nil,
updateRequired: nil,
},
{
name: "cannot parse OS version",
machineInfo: &fleet.MDMAppleMachineInfo{
MDMCanRequestSoftwareUpdate: true,
Product: "Mac15,7",
OSVersion: "INVALID",
SupplementalBuildVersion: "IRRELEVANT",
SoftwareUpdateDeviceID: "J516sAP",
},
updateRequired: nil,
err: "", // no error, allow enrollment to proceed without software update
},
}
t.Run("no team setting equal to latest", func(t *testing.T) {
setMinOSVersion(latestMacOSVersion, deadline, nil)
t.Run("sso disabled", func(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var mi *fleet.MDMAppleMachineInfo
if tc.machineInfo != nil {
miCopy := *tc.machineInfo
mi = &miCopy
mi.Serial = devices[0].SerialNumber
mi.UDID = uuid.New().String()
}
var expectEnrollInfo *mdmtest.AppleEnrollInfo
if mi != nil && tc.updateRequired == nil && tc.err == "" {
expectEnrollInfo = &mdmtest.AppleEnrollInfo{
SCEPChallenge: scepChallenge,
SCEPURL: scepURL,
MDMURL: mdmURL,
}
}
require.NoError(t, checkMDMEnrollEndpoint(t, mi, expectEnrollInfo, tc.updateRequired, tc.err, true))
})
}
})
t.Run("sso enabled", func(t *testing.T) {
setEnableEndUserAuth(true, nil)
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var mi *fleet.MDMAppleMachineInfo
if tc.machineInfo != nil {
miCopy := *tc.machineInfo
mi = &miCopy
mi.Serial = devices[0].SerialNumber
mi.UDID = uuid.New().String()
}
require.NoError(t, checkMDMSSOEndpoint(t, mi, tc.updateRequired, tc.err))
})
}
})
})
t.Run("team setting equal to latest", func(t *testing.T) {
setMinOSVersion(latestMacOSVersion, deadline, &team.ID)
t.Run("sso disabled", func(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var mi *fleet.MDMAppleMachineInfo
if tc.machineInfo != nil {
miCopy := *tc.machineInfo
mi = &miCopy
mi.Serial = devices[0].SerialNumber
mi.UDID = uuid.New().String()
}
require.NoError(t, checkMDMSSOEndpoint(t, mi, tc.updateRequired, tc.err))
var expectEnrollInfo *mdmtest.AppleEnrollInfo
if mi != nil && tc.updateRequired == nil && tc.err == "" {
expectEnrollInfo = &mdmtest.AppleEnrollInfo{
SCEPChallenge: "scepcha/><llenge",
SCEPURL: s.server.URL + "/mdm/apple/scep",
MDMURL: s.server.URL + "/mdm/apple/mdm",
}
}
require.NoError(t, checkMDMEnrollEndpoint(t, mi, expectEnrollInfo, tc.updateRequired, tc.err, true))
})
}
})
t.Run("sso enabled", func(t *testing.T) {
setEnableEndUserAuth(true, &team.ID)
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var mi *fleet.MDMAppleMachineInfo
if tc.machineInfo != nil {
miCopy := *tc.machineInfo
mi = &miCopy
mi.Serial = devices[0].SerialNumber
mi.UDID = uuid.New().String()
}
require.NoError(t, checkMDMSSOEndpoint(t, mi, tc.updateRequired, tc.err))
})
}
})
})
}
func (s *integrationMDMTestSuite) TestDeleteMultipleHostsPendingDEP() {
t := s.T()
ctx := context.Background()
devices := []godep.Device{
{SerialNumber: uuid.New().String(), Model: "MacBook Pro", OS: "osx", OpType: "added"},
{SerialNumber: uuid.New().String(), Model: "MacBook Mini", OS: "osx", OpType: "added"},
{SerialNumber: uuid.New().String(), Model: "MacBook Mini", OS: "osx", OpType: "added"},
{SerialNumber: uuid.New().String(), Model: "MacBook Mini", OS: "osx", OpType: "added"},
}
profileAssignmentReqs := []profileAssignmentReq{}
s.setSkipWorkerJobs(t)
s.enableABM(t.Name())
s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
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":
// This endpoint is used to get an initial list of
// devices, return a single device
err := encoder.Encode(godep.DeviceResponse{Devices: devices[:1]})
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: devices, 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))
profileAssignmentReqs = append(profileAssignmentReqs, 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(`{}`))
}
}))
// query all hosts
listHostsRes := listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
require.Empty(t, listHostsRes.Hosts) // no hosts yet
// trigger a profile sync
s.runDEPSchedule()
// all devices should be returned from the hosts endpoint
listHostsRes = listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
require.Len(t, listHostsRes.Hosts, len(devices))
bySerial := make(map[string]*fleet.HostResponse, len(devices))
for _, host := range listHostsRes.Hosts {
bySerial[host.HardwareSerial] = &host
}
for _, device := range devices {
h, ok := bySerial[device.SerialNumber]
require.True(t, ok)
// entries for all hosts get created in the host_dep_assignments table
hdep, err := s.ds.GetHostDEPAssignment(ctx, h.ID)
require.NoError(t, err)
require.NotNil(t, hdep)
require.Nil(t, hdep.DeletedAt)
}
// to confirm that hosts actually get recreated, we'll manually set the hosts.created_at
// time to 48 hours in the past (hopefully this interval is adequate avoid any flakiness in
// timestamp comparisons)
target := listHostsRes.Hosts[3]
then := target.CreatedAt.Add(-48 * time.Hour)
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `UPDATE hosts SET created_at = ? WHERE hardware_serial = ?`, then, target.HardwareSerial)
return err
})
hostResp := getHostResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", target.ID), nil, http.StatusOK, &hostResp)
require.Equal(t, then, hostResp.Host.CreatedAt)
// delete all hosts
deleteIds := []uint{}
for _, host := range listHostsRes.Hosts {
deleteIds = append(deleteIds, host.ID)
}
s.Do("POST", "/api/latest/fleet/hosts/delete", deleteHostsRequest{IDs: deleteIds}, http.StatusOK)
// all devices should be restored as pending DEP hosts
gotHosts := listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &gotHosts)
require.Len(t, gotHosts.Hosts, len(listHostsRes.Hosts))
for _, host := range gotHosts.Hosts {
h, ok := bySerial[host.HardwareSerial]
require.True(t, ok)
require.Equal(t, h.ID, host.ID) // host restored with the same id
if host.HardwareSerial == target.HardwareSerial {
require.NotEqual(t, then, host.CreatedAt)
}
}
}