fleet/server/service/integration_mdm_dep_test.go
Jahziel Villasana-Espinoza 385da248cb
feat: validate profile with apple (#21862)
> Related issue: #17558

# Checklist for submitter

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

<!-- Note that API documentation changes are now addressed by the
product design team. -->

- [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/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)
- [x] Added/updated tests
- [x] Manual QA for all new/changed functionality
2024-09-10 18:44:58 -04:00

1210 lines
52 KiB
Go

package service
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/fleetdm/fleet/v4/pkg/fleetdbase"
"github.com/fleetdm/fleet/v4/pkg/mdm/mdmtest"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/fleet"
"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/worker"
kitlog "github.com/go-kit/log"
"github.com/google/uuid"
"github.com/groob/plist"
"github.com/jmoiron/sqlx"
micromdm "github.com/micromdm/micromdm/mdm/mdm"
"github.com/stretchr/testify/require"
)
type profileAssignmentReq struct {
ProfileUUID string `json:"profile_uuid"`
Devices []string `json:"devices"`
}
func (s *integrationMDMTestSuite) TestDEPEnrollReleaseDeviceGlobal() {
t := s.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",
"issuer_uri": "http://localhost:8080/simplesaml/saml2/idp/SSOService.php",
"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")
for _, enableReleaseManually := range []bool{false, true} {
t.Run(fmt.Sprintf("enableReleaseManually=%t", enableReleaseManually), func(t *testing.T) {
s.runDEPEnrollReleaseDeviceTest(t, globalDevice, enableReleaseManually, nil, "I1")
})
}
}
func (s *integrationMDMTestSuite) TestDEPEnrollReleaseDeviceTeam() {
t := s.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",
"issuer_uri": "http://localhost:8080/simplesaml/saml2/idp/SSOService.php",
"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)
for _, enableReleaseManually := range []bool{false, true} {
t.Run(fmt.Sprintf("enableReleaseManually=%t", enableReleaseManually), func(t *testing.T) {
s.runDEPEnrollReleaseDeviceTest(t, teamDevice, enableReleaseManually, &tm.ID, "I2")
})
}
}
func (s *integrationMDMTestSuite) runDEPEnrollReleaseDeviceTest(t *testing.T, device godep.Device, enableReleaseManually bool, teamID *uint, customProfileIdent string) {
ctx := context.Background()
// set the enable release device manually option
payload := map[string]any{
"enable_release_device_manually": enableReleaseManually,
}
if teamID != nil {
payload["team_id"] = *teamID
}
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(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)
t.Cleanup(func() {
// delete the enrolled host
err := s.ds.DeleteHost(ctx, listHostsRes.Hosts[0].ID)
require.NoError(t, err)
})
// enroll the host
depURLToken := loadEnrollmentProfileDEPToken(t, s.ds)
mdmDevice := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, depURLToken)
mdmDevice.SerialNumber = device.SerialNumber
err := mdmDevice.Enroll()
require.NoError(t, err)
// run the worker to process the DEP enroll request
s.runWorker()
// run the worker to assign configuration profiles
s.awaitTriggerProfileSchedule(t)
var cmds []*micromdm.CommandPayload
cmd, err := mdmDevice.Idle()
require.NoError(t, err)
for cmd != nil {
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)
}
// expected commands: install fleetd, install bootstrap, install CA, install profiles
// (custom one, fleetd configuration, FileVault) (not expected: account
// configuration, since enrollment_reference not set)
require.Len(t, cmds, 6)
var installProfileCount, installEnterpriseCount, otherCount int
var profileCustomSeen, profileFleetdSeen, profileFleetCASeen, profileFileVaultSeen bool
for _, cmd := range cmds {
switch cmd.Command.RequestType {
case "InstallProfile":
installProfileCount++
if strings.Contains(string(cmd.Command.InstallProfile.Payload), fmt.Sprintf("<string>%s</string>", 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++
default:
otherCount++
}
}
require.Equal(t, 4, installProfileCount)
require.Equal(t, 2, installEnterpriseCount)
require.Equal(t, 0, otherCount)
require.True(t, profileCustomSeen)
require.True(t, profileFleetdSeen)
require.True(t, profileFleetCASeen)
require.True(t, profileFileVaultSeen)
if 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)
} else {
// there should be a Release Device pending job in the near future, expect
// it and schedule it to run now.
s.expectAndScheduleReleaseDeviceJob(t)
// run the worker to process the DEP release
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)
}
}
func (s *integrationMDMTestSuite) expectAndScheduleReleaseDeviceJob(t *testing.T) {
ctx := context.Background()
// get the worker's pending job from the future, there should be a DEP
// release device task
pending, err := s.ds.GetQueuedJobs(ctx, 1, time.Now().UTC().Add(time.Minute))
require.NoError(t, err)
require.Len(t, pending, 1)
releaseJob := pending[0]
require.Equal(t, 0, releaseJob.Retries)
require.Contains(t, string(*releaseJob.Args), worker.AppleMDMPostDEPReleaseDeviceTask)
// update the job so that it can run immediately
releaseJob.NotBefore = time.Now().UTC().Add(-time.Minute)
_, err = s.ds.UpdateJob(ctx, releaseJob.ID, releaseJob)
require.NoError(t, err)
}
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"`
}
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
}
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(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", "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
for _, h := range listHostsRes.Hosts {
if h.HardwareSerial == devices[1].SerialNumber {
err = s.ds.AddHostsToTeam(ctx, &team.ID, []uint{h.ID})
require.NoError(t, err)
}
}
// 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{}
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()
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 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", "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, ""))
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
{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
{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[0].Devices, 2)
require.ElementsMatch(t, []string{devices[2].SerialNumber, devices[3].SerialNumber}, profileAssignmentReqs[0].Devices)
checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].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: "added", ProfileUUID: defaultProfileUUID}, // matches existing profile
}
profileAssignmentReqs = []profileAssignmentReq{}
s.runDEPSchedule()
require.Empty(t, profileAssignmentReqs)
}
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)
}