Files
Nico f5ca4b5b0d Add Android support for custom host vitals (#49696)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #49421

Custom host vitals (`$FLEET_HOST_VITAL_<id>`) already worked in scripts
and Apple/Windows configuration profiles, but Android configuration
profiles and managed app configuration explicitly rejected them at
upload to keep parity with `$FLEET_SECRET_*`. This left admins unable to
inject per-host vitals (e.g. an asset tag) into Android MDM
configuration the same way they can for every other platform.

For more context, prior PRs:
- https://github.com/fleetdm/fleet/pull/49334
- https://github.com/fleetdm/fleet/pull/49586

# 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.

## Testing

- [x] Added/updated automated tests

- [x] QA'd all new/changed functionality manually

- Created an "Asset tag" host vital.
- Enrolled an Android device.
- Initially the test profile showed as "Failed" because no value was set
for the vital.
- Set a value for the vital, saw that it went from Enforcing to
Verified.

<img width="1446" height="510" alt="Screenshot 2026-07-24 at 8 57 46 AM"
src="https://github.com/user-attachments/assets/c0e2348c-e521-48f3-85cd-6f884689b2cd"
/>
<img width="1520" height="936" alt="Screenshot 2026-07-24 at 8 56 56 AM"
src="https://github.com/user-attachments/assets/169b9545-ec7a-429b-8f45-0e2740f61c77"
/>
<img width="1607" height="1136" alt="Screenshot 2026-07-24 at 8 57
30 AM"
src="https://github.com/user-attachments/assets/a8213745-b224-4a36-a54d-32152a15c377"
/>

Also tested the rejection cases:
- trying to upload a profile with an invalid custom host vital id
(either a non-numeric value, a numeric but non-existent ID, and
referencing a vital as a JSON key instead of a value)
- deleting a vital referenced in a profile



https://github.com/user-attachments/assets/e8b4acde-ddf4-41c0-b00a-5ab4945d0bc2



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

## Summary by CodeRabbit

* **New Features**
* Android app configurations and profiles now support custom host vital
placeholders (`$FLEET_HOST_VITAL_<id>`).
* Custom host vital values are expanded per device during Android
delivery.
* Managed Android profiles/configurations are automatically resent when
a referenced vital value changes.

* **Bug Fixes**
* Added validation for malformed, missing, or undefined vital references
during Android app association and profile/config uploads.
  * Prevented deletion of vitals referenced by Android profiles.
* Improved error handling and delivery failure details when a device
lacks a required vital value.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-29 08:22:57 -03:00

496 lines
18 KiB
Go

package service
import (
"context"
"database/sql"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/config"
authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/dev_mode"
"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/require"
)
func TestBatchAssociateVPPApps(t *testing.T) {
t.Parallel()
ds := new(mock.Store)
svc := newTestService(t, ds)
ctx := viewer.NewContext(t.Context(), viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}})
t.Run("Fails if missing VPP token when payloads to associate", func(t *testing.T) {
ds.GetVPPTokenByTeamIDFunc = func(ctx context.Context, teamID *uint) (*fleet.VPPTokenDB, error) {
return nil, sql.ErrNoRows
}
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
t.Run("dry run", func(t *testing.T) {
_, _, err := svc.BatchAssociateVPPApps(ctx, "", []fleet.VPPBatchPayload{
{
AppStoreID: "my-fake-app",
LabelsExcludeAny: []string{},
LabelsIncludeAny: []string{},
LabelsIncludeAll: []string{},
Categories: []string{},
Platform: fleet.MacOSPlatform,
},
}, true)
require.ErrorContains(t, err, "could not retrieve vpp token")
})
t.Run("not dry run", func(t *testing.T) {
_, _, err := svc.BatchAssociateVPPApps(ctx, "", []fleet.VPPBatchPayload{
{
AppStoreID: "my-fake-app",
LabelsExcludeAny: []string{},
LabelsIncludeAny: []string{},
LabelsIncludeAll: []string{},
Categories: []string{},
Platform: fleet.MacOSPlatform,
},
}, false)
require.ErrorContains(t, err, "could not retrieve vpp token")
})
})
t.Run("Rejects malformed custom host vital reference in Android app configuration", func(t *testing.T) {
ds.GetSoftwareCategoryNameToIDMapFunc = func(ctx context.Context, teamID uint, names []string) (map[string]uint, error) {
return nil, nil
}
_, _, err := svc.BatchAssociateVPPApps(ctx, "", []fleet.VPPBatchPayload{
{
AppStoreID: "com.example.app",
LabelsExcludeAny: []string{},
LabelsIncludeAny: []string{},
LabelsIncludeAll: []string{},
Categories: []string{},
Platform: fleet.AndroidPlatform,
Configuration: json.RawMessage(`{"managedConfiguration": {"assetTag": "$FLEET_HOST_VITAL_asset_tag"}}`),
},
}, true)
var badReqErr *fleet.BadRequestError
require.ErrorAs(t, err, &badReqErr)
require.ErrorContains(t, err, "Invalid custom host vital reference")
})
t.Run("Rejects Android app configuration referencing an unknown custom host vital", func(t *testing.T) {
ds.GetSoftwareCategoryNameToIDMapFunc = func(ctx context.Context, teamID uint, names []string) (map[string]uint, error) {
return nil, nil
}
ds.ValidateReferencedCustomHostVitalsFunc = func(ctx context.Context, documents []string) error {
return &fleet.MissingCustomHostVitalsError{MissingIDs: []uint{9}}
}
_, _, err := svc.BatchAssociateVPPApps(ctx, "", []fleet.VPPBatchPayload{
{
AppStoreID: "com.example.app",
LabelsExcludeAny: []string{},
LabelsIncludeAny: []string{},
LabelsIncludeAll: []string{},
Categories: []string{},
Platform: fleet.AndroidPlatform,
Configuration: json.RawMessage(`{"managedConfiguration": {"assetTag": "$FLEET_HOST_VITAL_9"}}`),
},
}, true)
var invalidArgErr *fleet.InvalidArgumentError
require.ErrorAs(t, err, &invalidArgErr)
require.ErrorContains(t, err, "is not defined")
})
t.Run("Android app configuration: infrastructure failure propagates instead of being reported as invalid input", func(t *testing.T) {
ds.GetSoftwareCategoryNameToIDMapFunc = func(ctx context.Context, teamID uint, names []string) (map[string]uint, error) {
return nil, nil
}
ds.ValidateReferencedCustomHostVitalsFunc = func(ctx context.Context, documents []string) error {
return ctxerr.Wrap(ctx, errors.New("connection refused"), "validating custom host vitals")
}
_, _, err := svc.BatchAssociateVPPApps(ctx, "", []fleet.VPPBatchPayload{
{
AppStoreID: "com.example.app",
LabelsExcludeAny: []string{},
LabelsIncludeAny: []string{},
LabelsIncludeAll: []string{},
Categories: []string{},
Platform: fleet.AndroidPlatform,
Configuration: json.RawMessage(`{"managedConfiguration": {"assetTag": "$FLEET_HOST_VITAL_9"}}`),
},
}, true)
require.Error(t, err)
require.ErrorContains(t, err, "connection refused")
var invalidArgErr2 *fleet.InvalidArgumentError
require.NotErrorAs(t, err, &invalidArgErr2, "an infrastructure failure must not be reported as invalid input (422)")
})
t.Run("Fails for Fleet Agent Android apps via GitOps", func(t *testing.T) {
ds.GetSoftwareCategoryNameToIDMapFunc = func(ctx context.Context, teamID uint, names []string) (map[string]uint, error) {
return nil, nil
}
fleetAgentPackages := []string{
"com.fleetdm.agent",
"com.fleetdm.agent.pingali",
"com.fleetdm.agent.private.testuser",
}
for _, pkg := range fleetAgentPackages {
t.Run(pkg+" dry run", func(t *testing.T) {
_, _, err := svc.BatchAssociateVPPApps(ctx, "", []fleet.VPPBatchPayload{
{
AppStoreID: pkg,
LabelsExcludeAny: []string{},
LabelsIncludeAny: []string{},
LabelsIncludeAll: []string{},
Categories: []string{},
Platform: fleet.AndroidPlatform,
},
}, true)
require.ErrorContains(t, err, "The Fleet agent cannot be added manually")
})
t.Run(pkg+" not dry run", func(t *testing.T) {
_, _, err := svc.BatchAssociateVPPApps(ctx, "", []fleet.VPPBatchPayload{
{
AppStoreID: pkg,
LabelsExcludeAny: []string{},
LabelsIncludeAny: []string{},
LabelsIncludeAll: []string{},
Categories: []string{},
Platform: fleet.AndroidPlatform,
},
}, false)
require.ErrorContains(t, err, "The Fleet agent cannot be added manually")
})
}
})
}
// TestGetAnchoredVPPAppsMetadataSkipsReAnchorOnEmptyMetadata guards against
// the row mismatch where reAnchors holds an entry for a (adamID, platform)
// whose metadata fetch was skipped because Apple returned blanks. Before the
// fix the trailing UpdateVPPAppCountryCode in BatchAssociateVPPApps would
// rewrite the row's country without a matching metadata insert, leaving the
// row internally inconsistent until the next refresh.
func TestGetAnchoredVPPAppsMetadataSkipsReAnchorOnEmptyMetadata(t *testing.T) {
// dev_mode.SetOverride uses t.Setenv, which is incompatible with t.Parallel.
// Fake Apple metadata endpoint that returns the requested adamID with a
// blank Name, the documented transiently-degraded path that the second
// loop's empty-metadata guard skips.
metaSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
type plat struct {
BundleID string `json:"bundleId"`
Artwork map[string]any `json:"artwork"`
LatestVersionRaw map[string]string `json:"latestVersionInfo"`
}
type attrs struct {
Name string `json:"name"`
DeviceFamilies []string `json:"deviceFamilies"`
Platforms map[string]plat `json:"platformAttributes"`
}
type meta struct {
ID string `json:"id"`
Attributes attrs `json:"attributes"`
}
type resp struct {
Data []meta `json:"data"`
}
out := resp{Data: []meta{{
ID: "100",
Attributes: attrs{
Name: "",
DeviceFamilies: []string{"mac"},
Platforms: map[string]plat{
"osx": {
BundleID: "com.example.100",
Artwork: map[string]any{"url": "https://example.test/icon.png"},
LatestVersionRaw: map[string]string{"versionDisplay": "1.0"},
},
},
},
}}}
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(out)
}))
t.Cleanup(metaSrv.Close)
dev_mode.SetOverride("FLEET_DEV_STOKEN_AUTHENTICATED_APPS_URL", metaSrv.URL, t)
ds := new(mock.Store)
// Existing row anchored to "us". The DE team adding it has no owning
// token in the anchored country, so resolveAddAnchor returns
// reAnchor=true with anchorCountry="de".
ds.GetVPPAppByAdamIDPlatformFunc = func(ctx context.Context, adamID string, platform fleet.InstallableDevicePlatform) (*fleet.VPPApp, error) {
return &fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: adamID, Platform: platform}},
CountryCode: "us",
Name: "Todoist US",
LatestVersion: "0.1",
}, nil
}
ds.GetVPPTokenOwningAppInCountryFunc = func(ctx context.Context, adamID string, platform fleet.InstallableDevicePlatform, country string) (*fleet.VPPTokenDB, error) {
return nil, &batchNotFoundError{}
}
authorizer, err := authz.NewAuthorizer()
require.NoError(t, err)
svc := &Service{
authz: authorizer,
ds: ds,
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
// Non-empty AppleConnectJWT so getVPPConfig's authenticator
// short-circuits to the JWT instead of querying the datastore.
config: config.FleetConfig{MDM: config.MDMConfig{AppleConnectJWT: "test-jwt"}},
}
apps, reAnchors, err := svc.getAnchoredVPPAppsMetadata(t.Context(),
[]fleet.VPPAppTeam{{VPPAppID: fleet.VPPAppID{AdamID: "100", Platform: fleet.MacOSPlatform}}},
vppTokenInfo{Secret: "de-secret", Country: "de"},
)
require.NoError(t, err)
require.Empty(t, apps, "row with empty Apple metadata must not be inserted")
require.Empty(t, reAnchors, "reAnchors must not contain entries for skipped rows")
}
// batchNotFoundError satisfies fleet.IsNotFound for the GetVPPTokenOwningAppInCountry mock.
type batchNotFoundError struct{}
func (batchNotFoundError) Error() string { return "not found" }
func (batchNotFoundError) IsNotFound() bool { return true }
// TestGetAppStoreAppsDoesNotWriteMetadata guards the picker against writing
// the team's current-storefront metadata onto rows whose stored country
// is anchored elsewhere.
func TestGetAppStoreAppsDoesNotWriteMetadata(t *testing.T) {
// dev_mode.SetOverride uses t.Setenv, incompatible with t.Parallel.
metaSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
type plat struct {
BundleID string `json:"bundleId"`
Artwork map[string]any `json:"artwork"`
LatestVersionRaw map[string]string `json:"latestVersionInfo"`
}
type attrs struct {
Name string `json:"name"`
DeviceFamilies []string `json:"deviceFamilies"`
Platforms map[string]plat `json:"platformAttributes"`
}
type meta struct {
ID string `json:"id"`
Attributes attrs `json:"attributes"`
}
type resp struct {
Data []meta `json:"data"`
}
out := resp{Data: []meta{{
ID: "100",
Attributes: attrs{
Name: "Todoist DE",
DeviceFamilies: []string{"mac"},
Platforms: map[string]plat{
"osx": {
BundleID: "com.example.100",
Artwork: map[string]any{"url": "https://example.test/de-icon.png"},
LatestVersionRaw: map[string]string{"versionDisplay": "9.9"},
},
},
},
}}}
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(out)
}))
t.Cleanup(metaSrv.Close)
dev_mode.SetOverride("FLEET_DEV_STOKEN_AUTHENTICATED_APPS_URL", metaSrv.URL, t)
vppSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"assets":[{"adamId":"100","pricingParam":"STDQ"}]}`))
}))
t.Cleanup(vppSrv.Close)
dev_mode.SetOverride("FLEET_DEV_VPP_URL", vppSrv.URL, t)
teamID := uint(1)
ds := new(mock.Store)
ds.GetVPPTokenByTeamIDFunc = func(ctx context.Context, _ *uint) (*fleet.VPPTokenDB, error) {
return &fleet.VPPTokenDB{
ID: 1,
OrgName: "de-org",
Token: "de-secret",
RenewDate: time.Now().Add(24 * time.Hour),
CountryCode: "de",
}, nil
}
// Existing row anchored to "us" while the team's current token is "de".
ds.GetAssignedVPPAppsFunc = func(ctx context.Context, _ *uint) (map[fleet.VPPAppID]fleet.VPPAppTeam, error) {
return map[fleet.VPPAppID]fleet.VPPAppTeam{
{AdamID: "100", Platform: fleet.MacOSPlatform}: {
VPPAppID: fleet.VPPAppID{AdamID: "100", Platform: fleet.MacOSPlatform},
},
}, nil
}
batchInsertCalled := false
ds.BatchInsertVPPAppsFunc = func(ctx context.Context, _ []*fleet.VPPApp) error {
batchInsertCalled = true
return nil
}
authorizer, err := authz.NewAuthorizer()
require.NoError(t, err)
svc := &Service{
authz: authorizer,
ds: ds,
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
config: config.FleetConfig{MDM: config.MDMConfig{AppleConnectJWT: "test-jwt"}},
}
ctx := viewer.NewContext(t.Context(), viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}})
apps, err := svc.GetAppStoreApps(ctx, &teamID)
require.NoError(t, err)
require.Empty(t, apps, "already-assigned apps must be filtered out of the picker list")
require.False(t, batchInsertCalled, "picker must not write metadata back")
}
// A no-platform numeric Adam ID expands to multiple (AdamID, platform)
// rows; the missing-asset error must surface each AdamID only once.
func TestBatchAssociateVPPAppsDedupsMissingAssetsError(t *testing.T) {
// dev_mode.SetOverride uses t.Setenv, which is incompatible with t.Parallel.
vppSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"assets":[]}`))
}))
t.Cleanup(vppSrv.Close)
dev_mode.SetOverride("FLEET_DEV_VPP_URL", vppSrv.URL, t)
ds := new(mock.Store)
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
ds.GetVPPTokenByTeamIDFunc = func(ctx context.Context, _ *uint) (*fleet.VPPTokenDB, error) {
return &fleet.VPPTokenDB{
ID: 1,
OrgName: "us-org",
Token: "us-secret",
RenewDate: time.Now().Add(24 * time.Hour),
CountryCode: "us",
}, nil
}
ds.GetSoftwareCategoryNameToIDMapFunc = func(ctx context.Context, _ uint, _ []string) (map[string]uint, error) {
return nil, nil
}
svc := newTestService(t, ds)
// ValidateSoftwareLabels inside the loop requires a present authz context
// for Authorize to mark it checked.
ctx := authz_ctx.NewContext(t.Context(), &authz_ctx.AuthorizationContext{})
ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}})
const adamID = "1107542306"
_, _, err := svc.BatchAssociateVPPApps(ctx, "", []fleet.VPPBatchPayload{
{
AppStoreID: adamID,
LabelsExcludeAny: []string{},
LabelsIncludeAny: []string{},
LabelsIncludeAll: []string{},
Categories: []string{},
// Empty Platform triggers the auto-expansion to multiple
// (AdamID, platform) rows — the multiplier this test guards.
},
}, false)
require.Error(t, err)
require.ErrorContains(t, err, "requested app not available on vpp account: "+adamID)
require.Equal(t, 1, strings.Count(err.Error(), adamID),
"missing-asset error must dedup by AdamID, got: %s", err.Error())
}
// TestGetVPPTokensScoping verifies that GetVPPTokens returns every token to
// global readers but scopes the list to a team-scoped user's readable teams
// (plus "All teams" tokens), without leaking tokens from teams the user can't
// read. See #46057.
func TestGetVPPTokensScoping(t *testing.T) {
ds := new(mock.Store)
// Tokens: team 1, team 2, "All teams" (non-nil empty Teams), and an
// unassigned token (nil Teams).
ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) {
return []*fleet.VPPTokenDB{
{ID: 1, OrgName: "team1", Teams: []fleet.TeamTuple{{ID: 1, Name: "Workstations"}}},
{ID: 2, OrgName: "team2", Teams: []fleet.TeamTuple{{ID: 2, Name: "Servers"}}},
{ID: 3, OrgName: "allteams", Teams: []fleet.TeamTuple{}},
{ID: 4, OrgName: "unassigned", Teams: nil},
}, nil
}
authorizer, err := authz.NewAuthorizer()
require.NoError(t, err)
svc := &Service{
authz: authorizer,
ds: ds,
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
globalMaintainer := &fleet.User{GlobalRole: new(fleet.RoleMaintainer)}
// Technician can read installable entities but not write them, so it must be
// able to read the token list to use the picker (#46057 names this role).
globalTechnician := &fleet.User{GlobalRole: new(fleet.RoleTechnician)}
teamMaintainer1 := &fleet.User{Teams: []fleet.UserTeam{
{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer},
}}
teamTechnician1 := &fleet.User{Teams: []fleet.UserTeam{
{Team: fleet.Team{ID: 1}, Role: fleet.RoleTechnician},
}}
// Observer on the first team, maintainer on the second: must still be
// authorized (via team 2) and scoped to team 2, never team 1.
observerThenMaintainer := &fleet.User{Teams: []fleet.UserTeam{
{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver},
{Team: fleet.Team{ID: 2}, Role: fleet.RoleMaintainer},
}}
teamObserver1 := &fleet.User{Teams: []fleet.UserTeam{
{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver},
}}
tests := []struct {
name string
user *fleet.User
wantErr bool
wantIDs []uint
}{
{"global admin sees all", &fleet.User{GlobalRole: new(fleet.RoleAdmin)}, false, []uint{1, 2, 3, 4}},
{"global maintainer sees all", globalMaintainer, false, []uint{1, 2, 3, 4}},
{"global technician sees all", globalTechnician, false, []uint{1, 2, 3, 4}},
{"team maintainer scoped to team + all-teams", teamMaintainer1, false, []uint{1, 3}},
{"team technician scoped to team + all-teams", teamTechnician1, false, []uint{1, 3}},
{"observer-then-maintainer scoped to second team", observerThenMaintainer, false, []uint{2, 3}},
{"team observer forbidden", teamObserver1, true, nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := viewer.NewContext(t.Context(), viewer.Viewer{User: tt.user})
got, err := svc.GetVPPTokens(ctx)
if tt.wantErr {
require.Error(t, err)
require.Equal(t, (&authz.Forbidden{}).Error(), err.Error())
return
}
require.NoError(t, err)
gotIDs := make([]uint, 0, len(got))
for _, tok := range got {
gotIDs = append(gotIDs, tok.ID)
}
require.ElementsMatch(t, tt.wantIDs, gotIDs)
})
}
}