<!-- 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 -->
496 lines
18 KiB
Go
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)
|
|
})
|
|
}
|
|
}
|