mirror of
https://github.com/fleetdm/fleet
synced 2026-04-27 16:37:55 +00:00
Putting this up for comments On certain errors(like a network error, perhaps even Apple ratelimiting) we previously would drop assignments during the DEP sync and leave the host_dep_assignments row null and the assignment unset on the Apple side. Because of how the sync works it is entirely possible when this happens that we would happily go along, update the cursor and never return to resync these devices unless and until the admin did something that forced a resync like changing something about the cloud config profile. Now any devices that for any reason don't get returned by the response get marked as failed so that our logic for retrying and processing cooldowns picks them up for later retry. Explanation here as far as what I think is going wrong: https://github.com/fleetdm/fleet/issues/31385#issuecomment-3145117080 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [ ] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. - [ ] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [ ] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes ## Testing - [x] Added/updated automated tests - [x] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [x] QA'd all new/changed functionality manually
431 lines
15 KiB
Go
431 lines
15 KiB
Go
package apple_mdm_test
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"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"
|
|
nanodep_client "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
|
|
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
|
|
"github.com/fleetdm/fleet/v4/server/test"
|
|
"github.com/go-kit/log"
|
|
"github.com/jmoiron/sqlx"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestDEPService_RunAssigner(t *testing.T) {
|
|
ctx := context.Background()
|
|
ds := mysql.CreateMySQLDS(t)
|
|
|
|
const abmTokenOrgName = "test_org"
|
|
depStorage, err := ds.NewMDMAppleDEPStorage()
|
|
require.NoError(t, err)
|
|
|
|
setupTest := func(t *testing.T, depHandler http.HandlerFunc) *apple_mdm.DEPService {
|
|
// start a server that will mock the Apple DEP API
|
|
srv := httptest.NewServer(depHandler)
|
|
t.Cleanup(srv.Close)
|
|
t.Cleanup(func() { mysql.TruncateTables(t, ds) })
|
|
|
|
err = depStorage.StoreConfig(ctx, abmTokenOrgName, &nanodep_client.Config{BaseURL: srv.URL})
|
|
require.NoError(t, err)
|
|
|
|
mysql.SetTestABMAssets(t, ds, abmTokenOrgName)
|
|
|
|
logger := log.NewNopLogger()
|
|
return apple_mdm.NewDEPService(ds, depStorage, logger)
|
|
}
|
|
|
|
t.Run("no custom profiles, no devices", func(t *testing.T) {
|
|
start := time.Now().Truncate(time.Second)
|
|
|
|
svc := setupTest(t, func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
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"}`, abmTokenOrgName)))
|
|
case "/profile":
|
|
err := encoder.Encode(godep.ProfileResponse{ProfileUUID: "profile123"})
|
|
require.NoError(t, err)
|
|
case "/server/devices":
|
|
err := encoder.Encode(godep.DeviceResponse{Devices: nil})
|
|
require.NoError(t, err)
|
|
case "/devices/sync":
|
|
err := encoder.Encode(godep.DeviceResponse{Devices: nil})
|
|
require.NoError(t, err)
|
|
default:
|
|
t.Errorf("unexpected request to %s", r.URL.Path)
|
|
}
|
|
})
|
|
err := svc.RunAssigner(ctx)
|
|
require.NoError(t, err)
|
|
|
|
// the default profile was created
|
|
defProf, err := ds.GetMDMAppleEnrollmentProfileByType(ctx, fleet.MDMAppleEnrollmentTypeAutomatic)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, defProf)
|
|
require.NotEmpty(t, defProf.Token)
|
|
|
|
// a profile UUID was assigned for no-team
|
|
profUUID, modTime, err := ds.GetMDMAppleDefaultSetupAssistant(ctx, nil, abmTokenOrgName)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "profile123", profUUID)
|
|
require.False(t, modTime.Before(start))
|
|
|
|
// no team to assign to
|
|
appCfg, err := ds.AppConfig(ctx)
|
|
require.NoError(t, err)
|
|
require.Empty(t, appCfg.MDM.DeprecatedAppleBMDefaultTeam)
|
|
abmTok, err := ds.GetABMTokenByOrgName(ctx, abmTokenOrgName)
|
|
require.NoError(t, err)
|
|
require.Nil(t, abmTok.MacOSDefaultTeamID)
|
|
require.Nil(t, abmTok.IPadOSDefaultTeamID)
|
|
require.Nil(t, abmTok.IOSDefaultTeamID)
|
|
|
|
// no teams, so no team-specific custom setup assistants
|
|
teams, err := ds.ListTeams(ctx, fleet.TeamFilter{User: test.UserAdmin}, fleet.ListOptions{})
|
|
require.NoError(t, err)
|
|
require.Empty(t, teams)
|
|
|
|
// no no-team custom setup assistant
|
|
_, err = ds.GetMDMAppleSetupAssistant(ctx, nil)
|
|
require.ErrorIs(t, err, sql.ErrNoRows)
|
|
|
|
// no host got created
|
|
hosts, err := ds.ListHosts(ctx, fleet.TeamFilter{User: test.UserAdmin}, fleet.HostListOptions{})
|
|
require.NoError(t, err)
|
|
require.Empty(t, hosts)
|
|
})
|
|
|
|
t.Run("no custom profiles, some devices", func(t *testing.T) {
|
|
start := time.Now().Truncate(time.Second)
|
|
|
|
devices := []godep.Device{
|
|
{SerialNumber: "a", OpType: "added"},
|
|
{SerialNumber: "b", OpType: "ignore"},
|
|
{SerialNumber: "c", OpType: ""},
|
|
}
|
|
|
|
var assignCalled bool
|
|
svc := setupTest(t, func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
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"}`, abmTokenOrgName)))
|
|
case "/profile":
|
|
err := encoder.Encode(godep.ProfileResponse{ProfileUUID: "profile123"})
|
|
require.NoError(t, err)
|
|
case "/server/devices":
|
|
err := encoder.Encode(godep.DeviceResponse{Devices: devices})
|
|
require.NoError(t, err)
|
|
case "/devices/sync":
|
|
err := encoder.Encode(godep.DeviceResponse{Devices: devices})
|
|
require.NoError(t, err)
|
|
case "/profile/devices":
|
|
assignCalled = true
|
|
|
|
reqBody, err := io.ReadAll(r.Body)
|
|
require.NoError(t, err)
|
|
|
|
var assignReq godep.Profile
|
|
err = json.Unmarshal(reqBody, &assignReq)
|
|
require.NoError(t, err)
|
|
require.Equal(t, assignReq.ProfileUUID, "profile123")
|
|
require.ElementsMatch(t, []string{"a", "c"}, assignReq.Devices)
|
|
|
|
apiResp := godep.ProfileResponse{
|
|
ProfileUUID: "profile123",
|
|
Devices: map[string]string{
|
|
"a": string(fleet.DEPAssignProfileResponseSuccess),
|
|
"c": string(fleet.DEPAssignProfileResponseSuccess),
|
|
},
|
|
}
|
|
respBytes, err := json.Marshal(&apiResp)
|
|
require.NoError(t, err)
|
|
|
|
_, err = w.Write(respBytes)
|
|
require.NoError(t, err)
|
|
default:
|
|
t.Errorf("unexpected request to %s", r.URL.Path)
|
|
}
|
|
})
|
|
err := svc.RunAssigner(ctx)
|
|
require.NoError(t, err)
|
|
require.True(t, assignCalled)
|
|
|
|
// the default profile was created
|
|
defProf, err := ds.GetMDMAppleEnrollmentProfileByType(ctx, fleet.MDMAppleEnrollmentTypeAutomatic)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, defProf)
|
|
require.NotEmpty(t, defProf.Token)
|
|
|
|
// a profile UUID was assigned to no-team
|
|
profUUID, modTime, err := ds.GetMDMAppleDefaultSetupAssistant(ctx, nil, abmTokenOrgName)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "profile123", profUUID)
|
|
require.False(t, modTime.Before(start))
|
|
|
|
// a couple hosts were created (except the op_type ignored)
|
|
hosts, err := ds.ListHosts(ctx, fleet.TeamFilter{User: test.UserAdmin}, fleet.HostListOptions{})
|
|
require.NoError(t, err)
|
|
require.Len(t, hosts, 2)
|
|
serials := make([]string, len(hosts))
|
|
for i, h := range hosts {
|
|
serials[i] = h.HardwareSerial
|
|
require.Nil(t, h.TeamID, h.HardwareSerial)
|
|
}
|
|
require.ElementsMatch(t, []string{"a", "c"}, serials)
|
|
|
|
mysql.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
stmt := "SELECT COUNT(*) FROM host_dep_assignments WHERE host_id IN (?, ?) AND assign_profile_response = ?"
|
|
var result int
|
|
require.NoError(t, sqlx.GetContext(ctx, q, &result, stmt, hosts[0].ID, hosts[1].ID, fleet.DEPAssignProfileResponseSuccess))
|
|
require.Equal(t, 2, result, "expected two successful assignments for serials a and c")
|
|
return nil
|
|
})
|
|
})
|
|
|
|
t.Run("a custom profile, some devices", func(t *testing.T) {
|
|
start := time.Now().Truncate(time.Second)
|
|
|
|
devices := []godep.Device{
|
|
{SerialNumber: "a", OpType: "added"},
|
|
{SerialNumber: "b", OpType: "ignore"},
|
|
{SerialNumber: "c", OpType: ""},
|
|
}
|
|
|
|
var assignCalled bool
|
|
svc := setupTest(t, func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
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"}`, abmTokenOrgName)))
|
|
case "/profile":
|
|
reqBody, err := io.ReadAll(r.Body)
|
|
require.NoError(t, err)
|
|
|
|
var defineReq godep.Profile
|
|
err = json.Unmarshal(reqBody, &defineReq)
|
|
require.NoError(t, err)
|
|
|
|
if defineReq.ProfileName == "team" {
|
|
err = encoder.Encode(godep.ProfileResponse{ProfileUUID: "profile456"})
|
|
require.NoError(t, err)
|
|
} else {
|
|
err = encoder.Encode(godep.ProfileResponse{ProfileUUID: "profile123"})
|
|
require.NoError(t, err)
|
|
}
|
|
case "/server/devices":
|
|
err := encoder.Encode(godep.DeviceResponse{Devices: devices})
|
|
require.NoError(t, err)
|
|
case "/devices/sync":
|
|
err := encoder.Encode(godep.DeviceResponse{Devices: devices})
|
|
require.NoError(t, err)
|
|
case "/profile/devices":
|
|
assignCalled = true
|
|
|
|
reqBody, err := io.ReadAll(r.Body)
|
|
require.NoError(t, err)
|
|
|
|
var assignReq godep.Profile
|
|
err = json.Unmarshal(reqBody, &assignReq)
|
|
require.NoError(t, err)
|
|
require.Equal(t, assignReq.ProfileUUID, "profile456")
|
|
require.ElementsMatch(t, []string{"a", "c"}, assignReq.Devices)
|
|
|
|
apiResp := godep.ProfileResponse{
|
|
ProfileUUID: "profile456",
|
|
Devices: map[string]string{
|
|
"a": string(fleet.DEPAssignProfileResponseSuccess),
|
|
"c": string(fleet.DEPAssignProfileResponseSuccess),
|
|
},
|
|
}
|
|
respBytes, err := json.Marshal(&apiResp)
|
|
require.NoError(t, err)
|
|
|
|
_, err = w.Write(respBytes)
|
|
require.NoError(t, err)
|
|
default:
|
|
t.Errorf("unexpected request to %s", r.URL.Path)
|
|
}
|
|
})
|
|
|
|
// create a team
|
|
tm, err := ds.NewTeam(ctx, &fleet.Team{Name: "test_team"})
|
|
require.NoError(t, err)
|
|
|
|
// set that team as default assignment for new macOS devices
|
|
tok, err := ds.GetABMTokenByOrgName(ctx, abmTokenOrgName)
|
|
require.NoError(t, err)
|
|
tok.MacOSDefaultTeamID = &tm.ID
|
|
err = ds.SaveABMToken(ctx, tok)
|
|
require.NoError(t, err)
|
|
|
|
// create a custom setup assistant for that team
|
|
tmAsst, err := ds.SetOrUpdateMDMAppleSetupAssistant(ctx, &fleet.MDMAppleSetupAssistant{
|
|
TeamID: &tm.ID,
|
|
Name: "test",
|
|
Profile: json.RawMessage(`{"profile_name": "team"}`),
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotZero(t, tmAsst.ID)
|
|
|
|
err = svc.RunAssigner(ctx)
|
|
require.NoError(t, err)
|
|
require.True(t, assignCalled)
|
|
|
|
// the default profile was created
|
|
defProf, err := ds.GetMDMAppleEnrollmentProfileByType(ctx, fleet.MDMAppleEnrollmentTypeAutomatic)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, defProf)
|
|
require.NotEmpty(t, defProf.Token)
|
|
|
|
// a profile UUID was assigned to the team
|
|
profUUID, modTime, err := ds.GetMDMAppleDefaultSetupAssistant(ctx, &tm.ID, abmTokenOrgName)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "profile123", profUUID)
|
|
require.False(t, modTime.Before(start))
|
|
|
|
// the team-specific custom profile was registered
|
|
tmAsst, err = ds.GetMDMAppleSetupAssistant(ctx, tmAsst.TeamID)
|
|
require.NoError(t, err)
|
|
require.False(t, tmAsst.UploadedAt.Before(start))
|
|
profileUUID, modTime, err := ds.GetMDMAppleSetupAssistantProfileForABMToken(ctx, &tm.ID, abmTokenOrgName)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "profile456", profileUUID)
|
|
require.True(t, tmAsst.UploadedAt.Equal(modTime))
|
|
|
|
// a couple hosts were created and assigned to the team (except the op_type ignored)
|
|
hosts, err := ds.ListHosts(ctx, fleet.TeamFilter{User: test.UserAdmin}, fleet.HostListOptions{})
|
|
require.NoError(t, err)
|
|
require.Len(t, hosts, 2)
|
|
serials := make([]string, len(hosts))
|
|
for i, h := range hosts {
|
|
serials[i] = h.HardwareSerial
|
|
require.NotNil(t, h.TeamID, h.HardwareSerial)
|
|
require.Equal(t, tm.ID, *h.TeamID, h.HardwareSerial)
|
|
}
|
|
require.ElementsMatch(t, []string{"a", "c"}, serials)
|
|
|
|
mysql.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
stmt := "SELECT COUNT(*) FROM host_dep_assignments WHERE host_id IN (?, ?) AND assign_profile_response = ?"
|
|
var result int
|
|
require.NoError(t, sqlx.GetContext(ctx, q, &result, stmt, hosts[0].ID, hosts[1].ID, fleet.DEPAssignProfileResponseSuccess))
|
|
require.Equal(t, 2, result, "expected two successful assignments for serials a and c")
|
|
return nil
|
|
})
|
|
})
|
|
|
|
t.Run("assign returns 5xx", func(t *testing.T) {
|
|
start := time.Now().Truncate(time.Second)
|
|
|
|
devices := []godep.Device{
|
|
{SerialNumber: "a", OpType: "added"},
|
|
{SerialNumber: "b", OpType: "ignore"},
|
|
{SerialNumber: "c", OpType: ""},
|
|
}
|
|
|
|
var assignCalled bool
|
|
svc := setupTest(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/profile/devices" {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
} else {
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
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"}`, abmTokenOrgName)))
|
|
case "/profile":
|
|
err := encoder.Encode(godep.ProfileResponse{ProfileUUID: "profile123"})
|
|
require.NoError(t, err)
|
|
case "/server/devices":
|
|
err := encoder.Encode(godep.DeviceResponse{Devices: devices})
|
|
require.NoError(t, err)
|
|
case "/devices/sync":
|
|
err := encoder.Encode(godep.DeviceResponse{Devices: devices})
|
|
require.NoError(t, err)
|
|
case "/profile/devices":
|
|
assignCalled = true
|
|
|
|
reqBody, err := io.ReadAll(r.Body)
|
|
require.NoError(t, err)
|
|
|
|
var assignReq godep.Profile
|
|
err = json.Unmarshal(reqBody, &assignReq)
|
|
require.NoError(t, err)
|
|
require.Equal(t, assignReq.ProfileUUID, "profile123")
|
|
require.ElementsMatch(t, []string{"a", "c"}, assignReq.Devices)
|
|
apiResp := godep.ProfileResponse{
|
|
ProfileUUID: "profile123",
|
|
Devices: map[string]string{
|
|
"a": string(fleet.DEPAssignProfileResponseSuccess),
|
|
"c": string(fleet.DEPAssignProfileResponseSuccess),
|
|
},
|
|
}
|
|
respBytes, err := json.Marshal(&apiResp)
|
|
require.NoError(t, err)
|
|
|
|
_, err = w.Write(respBytes)
|
|
require.NoError(t, err)
|
|
default:
|
|
t.Errorf("unexpected request to %s", r.URL.Path)
|
|
}
|
|
})
|
|
err := svc.RunAssigner(ctx)
|
|
require.NoError(t, err)
|
|
require.True(t, assignCalled)
|
|
|
|
// the default profile was created
|
|
defProf, err := ds.GetMDMAppleEnrollmentProfileByType(ctx, fleet.MDMAppleEnrollmentTypeAutomatic)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, defProf)
|
|
require.NotEmpty(t, defProf.Token)
|
|
|
|
// a profile UUID was assigned to no-team
|
|
profUUID, modTime, err := ds.GetMDMAppleDefaultSetupAssistant(ctx, nil, abmTokenOrgName)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "profile123", profUUID)
|
|
require.False(t, modTime.Before(start))
|
|
|
|
// a couple hosts were created (except the op_type ignored)
|
|
hosts, err := ds.ListHosts(ctx, fleet.TeamFilter{User: test.UserAdmin}, fleet.HostListOptions{})
|
|
require.NoError(t, err)
|
|
require.Len(t, hosts, 2)
|
|
serials := make([]string, len(hosts))
|
|
for i, h := range hosts {
|
|
serials[i] = h.HardwareSerial
|
|
require.Nil(t, h.TeamID, h.HardwareSerial)
|
|
}
|
|
require.ElementsMatch(t, []string{"a", "c"}, serials)
|
|
|
|
// Verify that the two hosts have assignments marked failed
|
|
mysql.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
stmt := "SELECT COUNT(*) FROM host_dep_assignments WHERE host_id IN (?, ?) AND assign_profile_response = ?"
|
|
var result int
|
|
require.NoError(t, sqlx.GetContext(ctx, q, &result, stmt, hosts[0].ID, hosts[1].ID, fleet.DEPAssignProfileResponseFailed))
|
|
require.Equal(t, 2, result, "expected two failed assignments for serials a and c")
|
|
return nil
|
|
})
|
|
})
|
|
}
|