SHAA: host dep details API (#42250)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #40794 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters. ## 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
This commit is contained in:
@@ -2026,7 +2026,9 @@ func unionSelectDevices(devices []hostToCreateFromMDM) (stmt string, args []inte
|
||||
func (ds *Datastore) GetHostDEPAssignment(ctx context.Context, hostID uint) (*fleet.HostDEPAssignment, error) {
|
||||
var res fleet.HostDEPAssignment
|
||||
err := sqlx.GetContext(ctx, ds.reader(ctx), &res, `
|
||||
SELECT host_id, added_at, deleted_at, abm_token_id, mdm_migration_deadline, mdm_migration_completed FROM host_dep_assignments hdep WHERE hdep.host_id = ?`, hostID)
|
||||
SELECT host_id, added_at, deleted_at, abm_token_id, mdm_migration_deadline, mdm_migration_completed,
|
||||
profile_uuid, assign_profile_response, response_updated_at
|
||||
FROM host_dep_assignments hdep WHERE hdep.host_id = ?`, hostID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ctxerr.Wrap(ctx, notFound("HostDEPAssignment").WithID(hostID))
|
||||
|
||||
@@ -6753,6 +6753,46 @@ func testMDMAppleDEPAssignmentUpdates(t *testing.T, ds *Datastore) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, h.ID, assignment.HostID)
|
||||
require.Nil(t, assignment.DeletedAt)
|
||||
// profile fields are nil before any assignment response has been recorded
|
||||
require.Nil(t, assignment.ProfileUUID)
|
||||
require.Nil(t, assignment.AssignProfileResponse)
|
||||
require.Nil(t, assignment.ResponseUpdatedAt)
|
||||
|
||||
// simulate a successful profile assignment response from Apple
|
||||
profileUUID := uuid.NewString()
|
||||
err = ds.UpdateHostDEPAssignProfileResponses(ctx, &godep.ProfileResponse{
|
||||
ProfileUUID: profileUUID,
|
||||
Devices: map[string]string{h.HardwareSerial: string(fleet.DEPAssignProfileResponseSuccess)},
|
||||
}, abmToken.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
beforeDelete, err := ds.GetHostDEPAssignment(ctx, h.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, h.ID, beforeDelete.HostID)
|
||||
require.Nil(t, beforeDelete.DeletedAt)
|
||||
// profile fields are now populated
|
||||
require.NotNil(t, beforeDelete.ProfileUUID)
|
||||
require.Equal(t, profileUUID, *beforeDelete.ProfileUUID)
|
||||
require.NotNil(t, beforeDelete.AssignProfileResponse)
|
||||
require.Equal(t, fleet.DEPAssignProfileResponseSuccess, *beforeDelete.AssignProfileResponse)
|
||||
require.NotNil(t, beforeDelete.ResponseUpdatedAt)
|
||||
require.WithinDuration(t, time.Now(), *beforeDelete.ResponseUpdatedAt, 5*time.Second)
|
||||
|
||||
// simulate a failed profile assignment response — fields should be updated
|
||||
profileUUID2 := uuid.NewString()
|
||||
err = ds.UpdateHostDEPAssignProfileResponses(ctx, &godep.ProfileResponse{
|
||||
ProfileUUID: profileUUID2,
|
||||
Devices: map[string]string{h.HardwareSerial: string(fleet.DEPAssignProfileResponseFailed)},
|
||||
}, abmToken.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
afterFail, err := ds.GetHostDEPAssignment(ctx, h.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, afterFail.ProfileUUID)
|
||||
require.Equal(t, profileUUID2, *afterFail.ProfileUUID)
|
||||
require.NotNil(t, afterFail.AssignProfileResponse)
|
||||
require.Equal(t, fleet.DEPAssignProfileResponseFailed, *afterFail.AssignProfileResponse)
|
||||
require.NotNil(t, afterFail.ResponseUpdatedAt)
|
||||
|
||||
err = ds.DeleteHostDEPAssignments(ctx, abmToken.ID, []string{h.HardwareSerial})
|
||||
require.NoError(t, err)
|
||||
@@ -6768,6 +6808,11 @@ func testMDMAppleDEPAssignmentUpdates(t *testing.T, ds *Datastore) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, h.ID, assignment.HostID)
|
||||
require.Nil(t, assignment.DeletedAt)
|
||||
// profile fields survive an upsert (the upsert only resets added_at/deleted_at)
|
||||
require.NotNil(t, assignment.ProfileUUID)
|
||||
require.Equal(t, profileUUID2, *assignment.ProfileUUID)
|
||||
require.NotNil(t, assignment.AssignProfileResponse)
|
||||
require.Equal(t, fleet.DEPAssignProfileResponseFailed, *assignment.AssignProfileResponse)
|
||||
}
|
||||
|
||||
func createRawAppleCmd(reqType, cmdUUID string) string {
|
||||
|
||||
@@ -526,22 +526,29 @@ func (p MDMAppleSetupPayload) AuthzType() string {
|
||||
// HostDEPAssignment represents a row in the host_dep_assignments table.
|
||||
type HostDEPAssignment struct {
|
||||
// HostID is the id of the host in Fleet.
|
||||
HostID uint `db:"host_id"`
|
||||
HostID uint `db:"host_id" json:"-"`
|
||||
// AddedAt is the timestamp when Fleet was notified that device was added to the Fleet MDM
|
||||
// server in Apple Busines Manager (ABM).
|
||||
AddedAt time.Time `db:"added_at"`
|
||||
AddedAt time.Time `db:"added_at" json:"added_at"`
|
||||
// DeletedAt is the timestamp when Fleet was notified that device was deleted from the Fleet
|
||||
// MDM server in Apple Busines Manager (ABM).
|
||||
DeletedAt *time.Time `db:"deleted_at"`
|
||||
DeletedAt *time.Time `db:"deleted_at" json:"deleted_at"`
|
||||
// ABMTokenID is the ID of the ABM token that was used to make this DEP assignment.
|
||||
ABMTokenID *uint `db:"abm_token_id"`
|
||||
ABMTokenID *uint `db:"abm_token_id" json:"abm_token_id"`
|
||||
// MDMMigrationDeadline is the deadline for the MDM migration received from ABM on the host's
|
||||
// most recent sync.
|
||||
MDMMigrationDeadline *time.Time `db:"mdm_migration_deadline"`
|
||||
MDMMigrationDeadline *time.Time `db:"mdm_migration_deadline" json:"mdm_migration_deadline,omitempty"`
|
||||
// MDMMigrationCompleted is the value of MDMMigrationDeadline when the host completed its last
|
||||
// Migration. Not a timestamp but a marker that the host completed the Migration for a given
|
||||
// date.
|
||||
MDMMigrationCompleted *time.Time `db:"mdm_migration_completed"`
|
||||
MDMMigrationCompleted *time.Time `db:"mdm_migration_completed" json:"mdm_migration_completed,omitempty"`
|
||||
// ProfileUUID is the UUID of the enrollment profile last assigned by Fleet via ABM.
|
||||
ProfileUUID *string `db:"profile_uuid" json:"profile_uuid,omitempty"`
|
||||
// AssignProfileResponse is the status returned by Apple when Fleet last
|
||||
// assigned the enrollment profile (SUCCESS, FAILED, NOT_ACCESSIBLE, THROTTLED).
|
||||
AssignProfileResponse *DEPAssignProfileResponseStatus `db:"assign_profile_response" json:"assign_profile_response,omitempty"`
|
||||
// ResponseUpdatedAt is the timestamp when AssignProfileResponse was last updated.
|
||||
ResponseUpdatedAt *time.Time `db:"response_updated_at" json:"response_updated_at,omitempty"`
|
||||
}
|
||||
|
||||
func (h *HostDEPAssignment) IsDEPAssignedToFleet() bool {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
|
||||
"github.com/fleetdm/fleet/v4/server/version"
|
||||
"github.com/fleetdm/fleet/v4/server/websocket"
|
||||
)
|
||||
@@ -881,6 +882,12 @@ type Service interface {
|
||||
// GetHostDEPAssignment retrieves the host DEP assignment for the specified host.
|
||||
GetHostDEPAssignment(ctx context.Context, host *Host) (*HostDEPAssignment, error)
|
||||
|
||||
// GetHostDEPAssignmentDetails retrieves Fleet's DEP assignment record and
|
||||
// Apple's live device details from ABM for the given host ID.
|
||||
// Returns (nil, nil, nil) for non-DEP hosts.
|
||||
// If ABM returns an error, dep_device is nil and the error is logged.
|
||||
GetHostDEPAssignmentDetails(ctx context.Context, hostID uint) (*HostDEPAssignment, *godep.Device, error)
|
||||
|
||||
// NewMDMAppleConfigProfile creates a new configuration profile for the specified team.
|
||||
NewMDMAppleConfigProfile(ctx context.Context, teamID uint, data []byte, labels []string, labelsMembershipMode MDMLabelsMode) (*MDMAppleConfigProfile, error)
|
||||
// NewMDMAppleConfigProfileWithPayload creates a new declaration for the specified team.
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
|
||||
"github.com/fleetdm/fleet/v4/server/version"
|
||||
"github.com/fleetdm/fleet/v4/server/websocket"
|
||||
)
|
||||
@@ -559,6 +560,8 @@ type BatchAssociateVPPAppsFunc func(ctx context.Context, teamName string, payloa
|
||||
|
||||
type GetHostDEPAssignmentFunc func(ctx context.Context, host *fleet.Host) (*fleet.HostDEPAssignment, error)
|
||||
|
||||
type GetHostDEPAssignmentDetailsFunc func(ctx context.Context, hostID uint) (*fleet.HostDEPAssignment, *godep.Device, error)
|
||||
|
||||
type NewMDMAppleConfigProfileFunc func(ctx context.Context, teamID uint, data []byte, labels []string, labelsMembershipMode fleet.MDMLabelsMode) (*fleet.MDMAppleConfigProfile, error)
|
||||
|
||||
type NewMDMAppleDeclarationFunc func(ctx context.Context, teamID uint, data []byte, labels []string, name string, labelsMembershipMode fleet.MDMLabelsMode) (*fleet.MDMAppleDeclaration, error)
|
||||
@@ -1708,6 +1711,9 @@ type Service struct {
|
||||
GetHostDEPAssignmentFunc GetHostDEPAssignmentFunc
|
||||
GetHostDEPAssignmentFuncInvoked bool
|
||||
|
||||
GetHostDEPAssignmentDetailsFunc GetHostDEPAssignmentDetailsFunc
|
||||
GetHostDEPAssignmentDetailsFuncInvoked bool
|
||||
|
||||
NewMDMAppleConfigProfileFunc NewMDMAppleConfigProfileFunc
|
||||
NewMDMAppleConfigProfileFuncInvoked bool
|
||||
|
||||
@@ -4108,6 +4114,13 @@ func (s *Service) GetHostDEPAssignment(ctx context.Context, host *fleet.Host) (*
|
||||
return s.GetHostDEPAssignmentFunc(ctx, host)
|
||||
}
|
||||
|
||||
func (s *Service) GetHostDEPAssignmentDetails(ctx context.Context, hostID uint) (*fleet.HostDEPAssignment, *godep.Device, error) {
|
||||
s.mu.Lock()
|
||||
s.GetHostDEPAssignmentDetailsFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.GetHostDEPAssignmentDetailsFunc(ctx, hostID)
|
||||
}
|
||||
|
||||
func (s *Service) NewMDMAppleConfigProfile(ctx context.Context, teamID uint, data []byte, labels []string, labelsMembershipMode fleet.MDMLabelsMode) (*fleet.MDMAppleConfigProfile, error) {
|
||||
s.mu.Lock()
|
||||
s.NewMDMAppleConfigProfileFuncInvoked = true
|
||||
|
||||
@@ -484,6 +484,8 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
|
||||
ue.GET("/api/_version_/fleet/hosts/summary/mdm", getHostMDMSummary, getHostMDMSummaryRequest{})
|
||||
ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/mdm", getHostMDM, getHostMDMRequest{})
|
||||
|
||||
ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/dep_assignment", getHostDEPAssignmentEndpoint, getHostDEPAssignmentRequest{})
|
||||
|
||||
ue.POST("/api/_version_/fleet/labels", createLabelEndpoint, createLabelRequest{})
|
||||
ue.PATCH("/api/_version_/fleet/labels/{id:[0-9]+}", modifyLabelEndpoint, modifyLabelRequest{})
|
||||
ue.GET("/api/_version_/fleet/labels/{id:[0-9]+}", getLabelEndpoint, getLabelRequest{})
|
||||
|
||||
@@ -29,9 +29,11 @@ import (
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm"
|
||||
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/assets"
|
||||
mdmlifecycle "github.com/fleetdm/fleet/v4/server/mdm/lifecycle"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
|
||||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||||
"github.com/fleetdm/fleet/v4/server/worker"
|
||||
"github.com/gocarina/gocsv"
|
||||
@@ -2340,6 +2342,96 @@ func (svc *Service) DeleteHostIDP(ctx context.Context, id uint) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// Get host DEP assignment
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type getHostDEPAssignmentRequest struct {
|
||||
ID uint `url:"id"`
|
||||
}
|
||||
|
||||
type getHostDEPAssignmentResponse struct {
|
||||
ID uint `json:"id"`
|
||||
HostDEPAssignment *fleet.HostDEPAssignment `json:"host_dep_assignment"`
|
||||
DEPDevice *godep.Device `json:"dep_device"`
|
||||
Err error `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (r getHostDEPAssignmentResponse) Error() error { return r.Err }
|
||||
|
||||
func getHostDEPAssignmentEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) {
|
||||
req := request.(*getHostDEPAssignmentRequest)
|
||||
depAssignment, depDevice, err := svc.GetHostDEPAssignmentDetails(ctx, req.ID)
|
||||
if err != nil {
|
||||
return getHostDEPAssignmentResponse{Err: err}, nil
|
||||
}
|
||||
|
||||
return getHostDEPAssignmentResponse{
|
||||
ID: req.ID,
|
||||
HostDEPAssignment: depAssignment,
|
||||
DEPDevice: depDevice,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (svc *Service) GetHostDEPAssignmentDetails(ctx context.Context, hostID uint) (*fleet.HostDEPAssignment, *godep.Device, error) {
|
||||
// Load the host first so we can do a team-aware authorization check,
|
||||
// mirroring what GET /hosts/:id does.
|
||||
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
host, err := svc.ds.HostLite(ctx, hostID)
|
||||
if err != nil {
|
||||
return nil, nil, ctxerr.Wrap(ctx, err, "get host for dep assignment")
|
||||
}
|
||||
|
||||
if err := svc.authz.Authorize(ctx, host, fleet.ActionRead); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Fetch Fleet's DEP assignment record. A not-found error means the host is
|
||||
// not a DEP host; return all nils so the response contains JSON nulls.
|
||||
depAssignment, err := svc.ds.GetHostDEPAssignment(ctx, hostID)
|
||||
if err != nil {
|
||||
if fleet.IsNotFound(err) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
return nil, nil, ctxerr.Wrap(ctx, err, "get host dep assignment")
|
||||
}
|
||||
|
||||
// Without an ABM token ID we can't resolve which org name to use for the
|
||||
// Apple API call, so return what we have from Fleet's DB.
|
||||
if depAssignment.ABMTokenID == nil {
|
||||
return depAssignment, nil, nil
|
||||
}
|
||||
|
||||
abmToken, err := svc.ds.GetABMTokenByID(ctx, *depAssignment.ABMTokenID)
|
||||
if err != nil {
|
||||
return nil, nil, ctxerr.Wrap(ctx, err, "get ABM token for dep assignment")
|
||||
}
|
||||
|
||||
// If Apple MDM is not configured (e.g. free tier), depStorage will be nil
|
||||
// and NewDEPClient would panic. Return what we have from Fleet's DB.
|
||||
if svc.depStorage == nil {
|
||||
return depAssignment, nil, nil
|
||||
}
|
||||
|
||||
// Call Apple's "Get Device Details" API. Per the issue spec: on error, log
|
||||
// and return dep_device as nil rather than surfacing the error to the caller.
|
||||
depClient := apple_mdm.NewDEPClient(svc.depStorage, svc.ds, svc.logger)
|
||||
depDevice, err := depClient.GetDeviceDetails(ctx, abmToken.OrganizationName, host.HardwareSerial)
|
||||
if err != nil {
|
||||
svc.logger.ErrorContext(ctx, "get DEP device details from ABM",
|
||||
"host_id", hostID,
|
||||
"org_name", abmToken.OrganizationName,
|
||||
"err", err,
|
||||
)
|
||||
return depAssignment, nil, nil
|
||||
}
|
||||
|
||||
return depAssignment, depDevice, nil
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// MDM
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -3236,3 +3237,222 @@ func (s *integrationMDMTestSuite) TestSoftwareInventoryForADEMacOSAfterWipeAndRe
|
||||
require.Equal(t, titleID2, getHostSw.Software[1].ID)
|
||||
require.Equal(t, installerPayload2.Title, getHostSw.Software[1].Name)
|
||||
}
|
||||
|
||||
func (s *integrationMDMTestSuite) TestGetHostDEPAssignment() {
|
||||
t := s.T()
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 1. Set up ABM / DEP mock infrastructure
|
||||
// ------------------------------------------------------------------
|
||||
orgName := t.Name()
|
||||
abmToken := s.enableABM(orgName)
|
||||
require.NotNil(t, abmToken)
|
||||
|
||||
// Serial number for the DEP device we will simulate
|
||||
depSerial := uuid.New().String()
|
||||
|
||||
// Apple's "Get Device Details" response that the mock ABM server will return
|
||||
// when our endpoint calls /devices.
|
||||
fakeProfileUUID := uuid.New().String()
|
||||
fakeProfileAssignTime := time.Now().UTC().Truncate(time.Second)
|
||||
fakeProfilePushTime := fakeProfileAssignTime.Add(5 * time.Second)
|
||||
|
||||
// Keep track of calls to the /devices endpoint so we can assert it was hit.
|
||||
// Use atomic.Int32 to avoid data races since the handler runs in a separate goroutine.
|
||||
var deviceDetailsCalled atomic.Int32
|
||||
|
||||
s.mockDEPResponse(orgName, 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": "xyz"}`))
|
||||
case "/account":
|
||||
_, _ = fmt.Fprintf(w, `{"admin_id": "abc", "org_name": %q}`, orgName)
|
||||
case "/profile":
|
||||
require.NoError(t, encoder.Encode(godep.ProfileResponse{ProfileUUID: fakeProfileUUID}))
|
||||
case "/server/devices":
|
||||
require.NoError(t, encoder.Encode(godep.DeviceResponse{Devices: []godep.Device{
|
||||
{
|
||||
SerialNumber: depSerial,
|
||||
Model: "MacBook Pro",
|
||||
OS: "osx",
|
||||
OpType: "added",
|
||||
ProfileStatus: "assigned",
|
||||
ProfileUUID: fakeProfileUUID,
|
||||
ProfileAssignTime: fakeProfileAssignTime,
|
||||
ProfilePushTime: fakeProfilePushTime,
|
||||
},
|
||||
}}))
|
||||
case "/devices/sync":
|
||||
require.NoError(t, encoder.Encode(godep.DeviceResponse{Cursor: "done"}))
|
||||
case "/profile/devices":
|
||||
b, err := io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
var req profileAssignmentReq
|
||||
require.NoError(t, json.Unmarshal(b, &req))
|
||||
resp := godep.ProfileResponse{ProfileUUID: req.ProfileUUID, Devices: make(map[string]string)}
|
||||
for _, d := range req.Devices {
|
||||
resp.Devices[d] = string(fleet.DEPAssignProfileResponseSuccess)
|
||||
}
|
||||
require.NoError(t, encoder.Encode(resp))
|
||||
case "/devices":
|
||||
// Apple's "Get Device Details" endpoint — called by our new endpoint.
|
||||
deviceDetailsCalled.Add(1)
|
||||
require.NoError(t, encoder.Encode(map[string]any{
|
||||
"devices": map[string]any{
|
||||
depSerial: map[string]any{
|
||||
"serial_number": depSerial,
|
||||
"model": "MacBook Pro",
|
||||
"profile_status": "assigned",
|
||||
"profile_uuid": fakeProfileUUID,
|
||||
"profile_assign_time": fakeProfileAssignTime.Format(time.RFC3339),
|
||||
"profile_push_time": fakeProfilePushTime.Format(time.RFC3339),
|
||||
"device_family": "Mac",
|
||||
},
|
||||
},
|
||||
}))
|
||||
default:
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
}
|
||||
}))
|
||||
|
||||
// Run the DEP sync so Fleet ingests the device and creates a host +
|
||||
// host_dep_assignments row.
|
||||
s.runDEPSchedule()
|
||||
|
||||
// Find the host that was just created by the DEP sync.
|
||||
var listResp listHostsResponse
|
||||
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listResp)
|
||||
var depHost *fleet.Host
|
||||
for _, h := range listResp.Hosts {
|
||||
if h.HardwareSerial == depSerial {
|
||||
depHost = h.Host
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, depHost, "expected to find DEP host after sync")
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 2. Happy path: DEP host returns both fleet record + Apple details
|
||||
// ------------------------------------------------------------------
|
||||
deviceDetailsCalled.Store(0)
|
||||
var depResp getHostDEPAssignmentResponse
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/dep_assignment", depHost.ID), nil, http.StatusOK, &depResp)
|
||||
|
||||
// host_dep_assignment must be present and reference the right host
|
||||
require.NotNil(t, depResp.HostDEPAssignment, "host_dep_assignment should not be nil for a DEP host")
|
||||
require.Equal(t, depHost.ID, depResp.ID)
|
||||
require.False(t, depResp.HostDEPAssignment.AddedAt.IsZero(), "added_at should be set")
|
||||
require.Nil(t, depResp.HostDEPAssignment.DeletedAt, "deleted_at should be nil for an active DEP host")
|
||||
// ABMTokenID is the FK linking this record to its ABM token and should be present in the response
|
||||
require.NotNil(t, depResp.HostDEPAssignment.ABMTokenID, "abm_token_id should be present in the response")
|
||||
// the DEP sync calls /profile/devices which writes profile_uuid, assign_profile_response, and response_updated_at
|
||||
require.NotNil(t, depResp.HostDEPAssignment.ProfileUUID, "profile_uuid should be set after DEP sync")
|
||||
require.Equal(t, fakeProfileUUID, *depResp.HostDEPAssignment.ProfileUUID)
|
||||
require.NotNil(t, depResp.HostDEPAssignment.AssignProfileResponse, "assign_profile_response should be set after DEP sync")
|
||||
require.Equal(t, fleet.DEPAssignProfileResponseSuccess, *depResp.HostDEPAssignment.AssignProfileResponse)
|
||||
require.NotNil(t, depResp.HostDEPAssignment.ResponseUpdatedAt, "response_updated_at should be set after DEP sync")
|
||||
// migration fields are not set during a plain DEP sync
|
||||
require.Nil(t, depResp.HostDEPAssignment.MDMMigrationDeadline, "mdm_migration_deadline should be nil for a freshly synced host")
|
||||
require.Nil(t, depResp.HostDEPAssignment.MDMMigrationCompleted, "mdm_migration_completed should be nil for a freshly synced host")
|
||||
|
||||
// Set a migration deadline and mark migration completed, then re-fetch to
|
||||
// confirm both fields are surfaced in the response.
|
||||
migrationDeadline := time.Now().Add(24 * time.Hour).UTC().Truncate(time.Second)
|
||||
require.NoError(t, s.ds.UpsertMDMAppleHostDEPAssignments(
|
||||
context.Background(),
|
||||
[]fleet.Host{*depHost},
|
||||
*depResp.HostDEPAssignment.ABMTokenID,
|
||||
map[uint]time.Time{depHost.ID: migrationDeadline},
|
||||
))
|
||||
require.NoError(t, s.ds.SetHostMDMMigrationCompleted(context.Background(), depHost.ID))
|
||||
|
||||
deviceDetailsCalled.Store(0)
|
||||
var migrationResp getHostDEPAssignmentResponse
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/dep_assignment", depHost.ID), nil, http.StatusOK, &migrationResp)
|
||||
|
||||
require.NotNil(t, migrationResp.HostDEPAssignment.MDMMigrationDeadline, "mdm_migration_deadline should be set after upsert")
|
||||
require.WithinDuration(t, migrationDeadline, *migrationResp.HostDEPAssignment.MDMMigrationDeadline, time.Second)
|
||||
require.NotNil(t, migrationResp.HostDEPAssignment.MDMMigrationCompleted, "mdm_migration_completed should be set after SetHostMDMMigrationCompleted")
|
||||
require.WithinDuration(t, migrationDeadline, *migrationResp.HostDEPAssignment.MDMMigrationCompleted, time.Second)
|
||||
|
||||
// dep_device must be present and contain Apple's live data
|
||||
require.NotNil(t, depResp.DEPDevice, "dep_device should not be nil for a DEP host with a valid ABM token")
|
||||
require.Equal(t, depSerial, depResp.DEPDevice.SerialNumber)
|
||||
require.Equal(t, "MacBook Pro", depResp.DEPDevice.Model)
|
||||
require.Equal(t, "assigned", depResp.DEPDevice.ProfileStatus)
|
||||
require.Equal(t, fakeProfileUUID, depResp.DEPDevice.ProfileUUID)
|
||||
|
||||
// The mock ABM /devices endpoint should have been called exactly once.
|
||||
require.Equal(t, int32(1), deviceDetailsCalled.Load(), "expected exactly one call to Apple's Get Device Details API")
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 3. Non-DEP host: both fields should be null
|
||||
// ------------------------------------------------------------------
|
||||
nonDEPHost := createOrbitEnrolledHost(t, "darwin", "non-dep", s.ds)
|
||||
|
||||
deviceDetailsCalled.Store(0)
|
||||
var nonDEPResp getHostDEPAssignmentResponse
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/dep_assignment", nonDEPHost.ID), nil, http.StatusOK, &nonDEPResp)
|
||||
|
||||
require.Nil(t, nonDEPResp.HostDEPAssignment, "host_dep_assignment should be null for a non-DEP host")
|
||||
require.Nil(t, nonDEPResp.DEPDevice, "dep_device should be null for a non-DEP host")
|
||||
// Apple's API should never be called for a non-DEP host.
|
||||
require.Equal(t, int32(0), deviceDetailsCalled.Load(), "Apple Get Device Details should not be called for a non-DEP host")
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 4. Non-Apple (Windows) host: both fields should be null, Apple API never called
|
||||
// ------------------------------------------------------------------
|
||||
windowsHost := createOrbitEnrolledHost(t, "windows", "non-apple", s.ds)
|
||||
|
||||
deviceDetailsCalled.Store(0)
|
||||
var windowsResp getHostDEPAssignmentResponse
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/dep_assignment", windowsHost.ID), nil, http.StatusOK, &windowsResp)
|
||||
|
||||
require.Nil(t, windowsResp.HostDEPAssignment, "host_dep_assignment should be null for a non-Apple host")
|
||||
require.Nil(t, windowsResp.DEPDevice, "dep_device should be null for a non-Apple host")
|
||||
// Apple's DEP API must never be called for a non-Apple host.
|
||||
require.Equal(t, int32(0), deviceDetailsCalled.Load(), "Apple Get Device Details should not be called for a non-Apple host")
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 5. ABM returns an error: host_dep_assignment is populated, dep_device is null
|
||||
// ------------------------------------------------------------------
|
||||
s.mockDEPResponse(orgName, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/session":
|
||||
_, _ = w.Write([]byte(`{"auth_session_token": "xyz"}`))
|
||||
case "/devices":
|
||||
// Simulate a server-side ABM error
|
||||
deviceDetailsCalled.Add(1)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"code":"INTERNAL_ERROR","message":"something went wrong"}`))
|
||||
default:
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
}
|
||||
}))
|
||||
|
||||
deviceDetailsCalled.Store(0)
|
||||
var abmErrResp getHostDEPAssignmentResponse
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/dep_assignment", depHost.ID), nil, http.StatusOK, &abmErrResp)
|
||||
|
||||
// Fleet's record should still come back
|
||||
require.NotNil(t, abmErrResp.HostDEPAssignment, "host_dep_assignment should still be returned when ABM errors")
|
||||
require.Equal(t, depHost.ID, abmErrResp.ID)
|
||||
// But Apple's data should be nil
|
||||
require.Nil(t, abmErrResp.DEPDevice, "dep_device should be null when ABM returns an error")
|
||||
// The mock endpoint was still called once (the attempt was made)
|
||||
require.Equal(t, int32(1), deviceDetailsCalled.Load(), "Apple Get Device Details should still be attempted even when it errors")
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 6. Unauthenticated request: should be rejected
|
||||
// ------------------------------------------------------------------
|
||||
savedToken := s.token
|
||||
s.token = "bad-token"
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/dep_assignment", depHost.ID), nil, http.StatusUnauthorized, &getHostDEPAssignmentResponse{})
|
||||
s.token = savedToken
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 7. Non-existent host: should return 404
|
||||
// ------------------------------------------------------------------
|
||||
s.DoJSON("GET", "/api/latest/fleet/hosts/999999/dep_assignment", nil, http.StatusNotFound, &getHostDEPAssignmentResponse{})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user