Files
fleet/server/service/devices_endpoint_test.go
NicoandLucas Manuel Rodriguez 358b8c43a5 iOS/iPadOS: More host vitals (#50496)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #39281 

# Checklist for submitter

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

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), 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] QA'd all new/changed functionality manually



https://github.com/user-attachments/assets/8ef7c66d-f8a6-46d5-ba17-66c1df8171b6



https://github.com/user-attachments/assets/6f67b49b-daf1-4acd-ad0f-55a7d1225a44



## Database migrations

- [x] Checked schema for all modified table for columns that will
auto-update timestamps during migration.
- [x] Confirmed that updating the timestamps is acceptable, and will not
cause unwanted side effects.
- [x] Ensured the correct collation is explicitly set for character
columns (`COLLATE utf8mb4_unicode_ci`).


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * Added 29 iOS/iPadOS device vitals to host details and API responses.
* Added a “View all” modal for browsing complete device vitals,
including subscriptions and accessibility details.
* Added support for cellular technology, organization information,
attestation data, and device settings.
* **Privacy**
  * Personal/BYOD enrollments exclude expanded device-vitals fields.
* Sensitive vitals are removed from device URL-authenticated responses.
* **Bug Fixes**
* Stale vitals are cleared when enrollment ownership changes or hosts
are deleted.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Lucas Manuel Rodriguez <lucas@fleetdm.com>
2026-08-07 09:49:14 -03:00

433 lines
15 KiB
Go

package service
import (
"context"
"testing"
"time"
"github.com/fleetdm/fleet/v4/pkg/optjson"
"github.com/fleetdm/fleet/v4/server/contexts/authz"
"github.com/fleetdm/fleet/v4/server/contexts/host"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mock"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetDeviceHostEndpointScrubbing(t *testing.T) {
ds := new(mock.Store)
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{SkipCreateTestUsers: true})
h := &fleet.Host{
ID: 1,
Hostname: "test-host",
UUID: "sensitive-uuid",
HardwareSerial: "sensitive-serial",
PrimaryMac: "sensitive-mac",
TeamName: ptr.String("sensitive-team"),
Platform: "ios",
MDM: fleet.MDMHostData{
Profiles: &[]fleet.HostMDMProfile{
{Identifier: "sensitive-profile"},
},
},
}
ds.HostLiteFunc = func(ctx context.Context, id uint) (*fleet.Host, error) {
return h, nil
}
ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) {
return h, nil
}
ds.GetHostIssuesLastUpdatedFunc = func(ctx context.Context, hostID uint) (time.Time, error) {
return time.Now(), nil
}
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{
OrgInfo: fleet.OrgInfo{
OrgLogoURL: "http://example.com/logo.png",
},
}, nil
}
ds.LoadHostSoftwareFunc = func(ctx context.Context, host *fleet.Host, includeVulnerabilities bool) error {
return nil
}
ds.LoadHostMDMAppleDeviceVitalsFunc = func(ctx context.Context, host *fleet.Host) error {
host.HostMDMAppleDeviceVitals = fleet.HostMDMAppleDeviceVitals{
PushToken: []byte("sensitive-push-token"),
ServiceSubscriptions: []fleet.MDMAppleServiceSubscription{
{Slot: "CTSubscriptionSlotOne", PhoneNumber: new("+15555550100")},
},
AccessibilitySettings: &fleet.MDMAppleAccessibilitySettings{
VoiceOverEnabled: new(true),
},
}
return nil
}
ds.ListPoliciesForHostFunc = func(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) {
return nil, nil
}
ds.ListHostUsersFunc = func(ctx context.Context, hostID uint) ([]fleet.HostUser, error) {
return nil, nil
}
ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) {
return nil, nil
}
ds.GetHostMDMCheckinInfoFunc = func(ctx context.Context, hostUUID string) (*fleet.HostMDMCheckinInfo, error) {
return nil, nil
}
ds.ListLabelsForHostFunc = func(ctx context.Context, hostID uint) ([]*fleet.Label, error) {
return nil, nil
}
ds.ListPacksForHostFunc = func(ctx context.Context, hostID uint) ([]*fleet.Pack, error) {
return nil, nil
}
ds.ListHostBatteriesFunc = func(ctx context.Context, id uint) ([]*fleet.HostBattery, error) {
return nil, nil
}
ds.ListUpcomingHostMaintenanceWindowsFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostMaintenanceWindow, error) {
return nil, nil
}
ds.IsHostDiskEncryptionKeyArchivedFunc = func(ctx context.Context, hostID uint) (bool, error) {
return false, nil
}
ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) {
return &fleet.HostLockWipeStatus{}, nil
}
ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) {
return nil, nil
}
ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) {
return nil, nil
}
ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) {
return nil, nil
}
ds.GetHostCustomHostVitalsFunc = func(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) {
return nil, nil
}
// Inject host into context
ctx = host.NewContext(ctx, h)
// Inject authz context with URL-based auth method (scrubbing only happens for URL auth)
authzCtx := &authz.AuthorizationContext{}
authzCtx.SetAuthnMethod(authz.AuthnDeviceURL)
ctx = authz.NewContext(ctx, authzCtx)
req := &getDeviceHostRequest{
Token: "test-token",
}
resp, err := getDeviceHostEndpoint(ctx, req, svc)
require.NoError(t, err)
deviceResp, ok := resp.(getDeviceHostResponse)
require.True(t, ok)
require.NoError(t, deviceResp.Err)
require.NotNil(t, deviceResp.Host)
// Verify scrubbed fields in Host
assert.Empty(t, deviceResp.Host.HardwareSerial)
assert.Empty(t, deviceResp.Host.UUID)
assert.Empty(t, deviceResp.Host.PrimaryMac)
assert.Nil(t, deviceResp.Host.TeamName)
assert.Nil(t, deviceResp.Host.MDM.Profiles)
assert.Nil(t, deviceResp.Host.Labels)
// Verify the new iOS/iPadOS device vitals (#49984) are scrubbed too, not
// just the fields that existed when this scrub block was first written.
assert.Equal(t, fleet.HostMDMAppleDeviceVitals{}, deviceResp.Host.HostMDMAppleDeviceVitals)
// Verify scrubbed fields in License
assert.Empty(t, deviceResp.License.Organization)
assert.Zero(t, deviceResp.License.DeviceCount)
assert.True(t, deviceResp.License.Expiration.IsZero())
assert.Equal(t, "", deviceResp.Host.Hostname)
assert.Equal(t, "", deviceResp.Host.ComputerName)
assert.Equal(t, "", deviceResp.Host.DisplayText)
assert.Equal(t, "", deviceResp.Host.DisplayName)
// Verify other fields are present
assert.Equal(t, "http://example.com/logo.png", deviceResp.OrgLogoURL)
}
func TestGetDeviceHostEndpointNoScrubbingForMacOS(t *testing.T) {
ds := new(mock.Store)
testLicense := &fleet.LicenseInfo{
Tier: fleet.TierPremium,
Organization: "Test Org",
DeviceCount: 100,
Expiration: time.Now().Add(24 * time.Hour),
}
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{
SkipCreateTestUsers: true,
License: testLicense,
})
h := &fleet.Host{
ID: 1,
Hostname: "test-host-mac",
UUID: "visible-uuid",
HardwareSerial: "visible-serial",
PrimaryMac: "visible-mac",
TeamName: ptr.String("visible-team"),
Platform: "darwin",
MDM: fleet.MDMHostData{
Profiles: &[]fleet.HostMDMProfile{
{Identifier: "visible-profile"},
},
},
}
ds.HostLiteFunc = func(ctx context.Context, id uint) (*fleet.Host, error) {
return h, nil
}
ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) {
return h, nil
}
ds.GetHostIssuesLastUpdatedFunc = func(ctx context.Context, hostID uint) (time.Time, error) {
return time.Now(), nil
}
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{
OrgInfo: fleet.OrgInfo{
OrgLogoURL: "http://example.com/logo.png",
},
}, nil
}
ds.LoadHostSoftwareFunc = func(ctx context.Context, host *fleet.Host, includeVulnerabilities bool) error {
return nil
}
ds.LoadHostMDMAppleDeviceVitalsFunc = func(ctx context.Context, host *fleet.Host) error {
return nil
}
ds.ListPoliciesForHostFunc = func(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) {
return nil, nil
}
ds.ListHostUsersFunc = func(ctx context.Context, hostID uint) ([]fleet.HostUser, error) {
return nil, nil
}
ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) {
return nil, nil
}
ds.GetHostMDMCheckinInfoFunc = func(ctx context.Context, hostUUID string) (*fleet.HostMDMCheckinInfo, error) {
return nil, nil
}
ds.ListLabelsForHostFunc = func(ctx context.Context, hostID uint) ([]*fleet.Label, error) {
return nil, nil
}
ds.ListPacksForHostFunc = func(ctx context.Context, hostID uint) ([]*fleet.Pack, error) {
return nil, nil
}
ds.ListHostBatteriesFunc = func(ctx context.Context, id uint) ([]*fleet.HostBattery, error) {
return nil, nil
}
ds.ListUpcomingHostMaintenanceWindowsFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostMaintenanceWindow, error) {
return nil, nil
}
ds.IsHostDiskEncryptionKeyArchivedFunc = func(ctx context.Context, hostID uint) (bool, error) {
return false, nil
}
ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) {
return &fleet.HostLockWipeStatus{}, nil
}
ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) {
return nil, nil
}
ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) {
return nil, nil
}
ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) {
return nil, nil
}
ds.GetHostCustomHostVitalsFunc = func(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) {
return nil, nil
}
// Inject host into context
ctx = host.NewContext(ctx, h)
// Inject authz context
authzCtx := &authz.AuthorizationContext{}
authzCtx.SetAuthnMethod(authz.AuthnDeviceToken)
ctx = authz.NewContext(ctx, authzCtx)
req := &getDeviceHostRequest{
Token: "test-token",
}
resp, err := getDeviceHostEndpoint(ctx, req, svc)
require.NoError(t, err)
deviceResp, ok := resp.(getDeviceHostResponse)
require.True(t, ok)
require.NoError(t, deviceResp.Err)
require.NotNil(t, deviceResp.Host)
// Verify fields are NOT scrubbed
assert.Equal(t, "visible-serial", deviceResp.Host.HardwareSerial)
assert.Equal(t, "visible-uuid", deviceResp.Host.UUID)
assert.Equal(t, "visible-mac", deviceResp.Host.PrimaryMac)
assert.NotNil(t, deviceResp.Host.TeamName)
assert.Equal(t, "visible-team", *deviceResp.Host.TeamName)
assert.NotNil(t, deviceResp.Host.MDM.Profiles)
// Verify License is NOT scrubbed (values match what we set in testLicense)
assert.Equal(t, "Test Org", deviceResp.License.Organization)
assert.Equal(t, 100, deviceResp.License.DeviceCount)
assert.False(t, deviceResp.License.Expiration.IsZero())
}
func TestGetDeviceHostEndpointConditionalAccessBypass(t *testing.T) {
// Tests for EnableConditionalAccessBypass in DeviceFeatures for hosts WITHOUT teams.
// For hosts without a team, EnableConditionalAccess is always false because
// conditional access requires team membership + global Okta config + team config.
// EnableConditionalAccessBypass is controlled solely by AppConfig.ConditionalAccess.
cases := []struct {
name string
conditionalAccessConfig *fleet.ConditionalAccessSettings
expectedEnableConditionalAccess bool
expectedEnableBypass bool
}{
{
name: "No ConditionalAccess config",
conditionalAccessConfig: nil,
expectedEnableConditionalAccess: false,
expectedEnableBypass: false,
},
{
name: "ConditionalAccess set, bypass default (BypassDisabled not set)",
conditionalAccessConfig: &fleet.ConditionalAccessSettings{
// BypassDisabled not set (Valid=false) -> bypass enabled by default
},
expectedEnableConditionalAccess: false,
expectedEnableBypass: true,
},
{
name: "ConditionalAccess set, bypass explicitly disabled",
conditionalAccessConfig: &fleet.ConditionalAccessSettings{
BypassDisabled: optjson.SetBool(true),
},
expectedEnableConditionalAccess: false,
expectedEnableBypass: false,
},
{
name: "ConditionalAccess set, bypass explicitly enabled",
conditionalAccessConfig: &fleet.ConditionalAccessSettings{
BypassDisabled: optjson.SetBool(false),
},
expectedEnableConditionalAccess: false,
expectedEnableBypass: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ds := new(mock.Store)
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{SkipCreateTestUsers: true})
h := &fleet.Host{
ID: 1,
Hostname: "test-host",
Platform: "darwin",
// TeamID is nil - host has no team
}
ds.HostLiteFunc = func(ctx context.Context, id uint) (*fleet.Host, error) {
return h, nil
}
ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) {
return h, nil
}
ds.GetHostIssuesLastUpdatedFunc = func(ctx context.Context, hostID uint) (time.Time, error) {
return time.Now(), nil
}
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{
OrgInfo: fleet.OrgInfo{
OrgLogoURL: "http://example.com/logo.png",
},
ConditionalAccess: tc.conditionalAccessConfig,
}, nil
}
ds.LoadHostSoftwareFunc = func(ctx context.Context, host *fleet.Host, includeVulnerabilities bool) error {
return nil
}
ds.ListPoliciesForHostFunc = func(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) {
return nil, nil
}
ds.ListHostUsersFunc = func(ctx context.Context, hostID uint) ([]fleet.HostUser, error) {
return nil, nil
}
ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) {
return nil, nil
}
ds.GetHostMDMCheckinInfoFunc = func(ctx context.Context, hostUUID string) (*fleet.HostMDMCheckinInfo, error) {
return nil, nil
}
ds.ListLabelsForHostFunc = func(ctx context.Context, hostID uint) ([]*fleet.Label, error) {
return nil, nil
}
ds.ListPacksForHostFunc = func(ctx context.Context, hostID uint) ([]*fleet.Pack, error) {
return nil, nil
}
ds.ListHostBatteriesFunc = func(ctx context.Context, id uint) ([]*fleet.HostBattery, error) {
return nil, nil
}
ds.ListUpcomingHostMaintenanceWindowsFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostMaintenanceWindow, error) {
return nil, nil
}
ds.IsHostDiskEncryptionKeyArchivedFunc = func(ctx context.Context, hostID uint) (bool, error) {
return false, nil
}
ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) {
return &fleet.HostLockWipeStatus{}, nil
}
ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) {
return nil, nil
}
ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) {
return nil, nil
}
ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) {
return nil, nil
}
ds.GetHostCustomHostVitalsFunc = func(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) {
return nil, nil
}
// Inject host into context
ctx = host.NewContext(ctx, h)
// Inject authz context
authzCtx := &authz.AuthorizationContext{}
authzCtx.SetAuthnMethod(authz.AuthnDeviceToken)
ctx = authz.NewContext(ctx, authzCtx)
req := &getDeviceHostRequest{
Token: "test-token",
}
resp, err := getDeviceHostEndpoint(ctx, req, svc)
require.NoError(t, err)
deviceResp, ok := resp.(getDeviceHostResponse)
require.True(t, ok)
require.NoError(t, deviceResp.Err)
// Verify conditional access features
assert.Equal(t, tc.expectedEnableConditionalAccess, deviceResp.GlobalConfig.Features.EnableConditionalAccess,
"EnableConditionalAccess mismatch")
assert.Equal(t, tc.expectedEnableBypass, deviceResp.GlobalConfig.Features.EnableConditionalAccessBypass,
"EnableConditionalAccessBypass mismatch")
})
}
}