mirror of
https://github.com/fleetdm/fleet
synced 2026-05-22 08:28:52 +00:00
2795 lines
112 KiB
Go
2795 lines
112 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/pkg/optjson"
|
|
"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, "", false)
|
|
|
|
// 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)
|
|
t.Cleanup(func() {
|
|
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
|
|
"mdm": {
|
|
"macos_setup": {
|
|
"enable_end_user_authentication": false
|
|
}
|
|
}
|
|
}`), http.StatusOK, &acResp)
|
|
})
|
|
|
|
// 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 a migrating host
|
|
migratingDevice := globalDevice
|
|
migratingDevice.MDMMigrationDeadline = ptr.Time(time.Now().Add(24 * time.Hour))
|
|
for _, enableReleaseManually := range []bool{false, true} {
|
|
t.Run(fmt.Sprintf("enableReleaseManually=%t;new_flow_with_DEP_migration", enableReleaseManually), func(t *testing.T) {
|
|
s.runDEPEnrollReleaseDeviceTest(t, migratingDevice, 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,
|
|
BootstrapPackage: 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, "", false)
|
|
|
|
// 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)
|
|
t.Cleanup(func() {
|
|
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
|
|
"mdm": {
|
|
"macos_setup": {
|
|
"enable_end_user_authentication": false
|
|
}
|
|
}
|
|
}`), http.StatusOK, &acResp)
|
|
})
|
|
|
|
// 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,
|
|
BootstrapPackage: 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
|
|
BootstrapPackage 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
|
|
if opts.BootstrapPackage {
|
|
team, err := s.ds.Team(ctx, *opts.TeamID)
|
|
require.NoError(t, err)
|
|
|
|
team.Config.MDM.MacOSSetup.BootstrapPackage = optjson.SetString("bootstrap.pkg")
|
|
_, err = s.ds.SaveTeam(ctx, team)
|
|
require.NoError(t, err)
|
|
}
|
|
} else if opts.BootstrapPackage {
|
|
ac, err := s.ds.AppConfig(ctx)
|
|
require.NoError(t, err)
|
|
|
|
ac.MDM.MacOSSetup.BootstrapPackage = optjson.SetString("bootstrap.pkg")
|
|
err = s.ds.SaveAppConfig(ctx, ac)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
s.Do("PATCH", "/api/latest/fleet/setup_experience", json.RawMessage(jsonMustMarshal(t, payload)), http.StatusNoContent)
|
|
t.Cleanup(func() {
|
|
// Get back to the default state.
|
|
if opts.BootstrapPackage {
|
|
if opts.TeamID != nil {
|
|
team, err := s.ds.Team(ctx, *opts.TeamID)
|
|
require.NoError(t, err)
|
|
|
|
team.Config.MDM.MacOSSetup.BootstrapPackage = optjson.String{}
|
|
_, err = s.ds.SaveTeam(ctx, team)
|
|
require.NoError(t, err)
|
|
} else {
|
|
ac, err := s.ds.AppConfig(ctx)
|
|
require.NoError(t, err)
|
|
|
|
ac.MDM.MacOSSetup.BootstrapPackage = optjson.String{}
|
|
err = s.ds.SaveAppConfig(ctx, ac)
|
|
require.NoError(t, err)
|
|
}
|
|
}
|
|
|
|
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) || device.MDMMigrationDeadline != nil {
|
|
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)
|
|
}
|
|
|
|
}
|
|
}
|