Add custom VPP app support (#37969)

Resolves #32481 for Fleet server-side work.

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [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)

## Testing

- [x] Added/updated automated tests

- [x] QA'd all new/changed functionality manually
This commit is contained in:
Ian Littman
2026-01-08 13:13:04 -06:00
committed by GitHub
parent b3bd4686a3
commit b1915800e2
20 changed files with 1267 additions and 594 deletions
+36 -36
View File
@@ -48,6 +48,8 @@ type InstallerStore interface {
// Datastore combines all the interfaces in the Fleet DAL
type Datastore interface {
GetsAppConfig
AccessesMDMConfigAssets
health.Checker
CarveStore
@@ -481,7 +483,6 @@ type Datastore interface {
// AppConfigStore contains method for saving and retrieving application configuration
NewAppConfig(ctx context.Context, info *AppConfig) (*AppConfig, error)
AppConfig(ctx context.Context) (*AppConfig, error)
SaveAppConfig(ctx context.Context, info *AppConfig) error
// GetEnrollSecrets gets the enroll secrets for a team (or global if teamID is nil).
@@ -1576,41 +1577,6 @@ type Datastore interface {
// GetMDMAppleOSUpdatesSettingsByHostSerial returns applicable Apple OS update settings (if any)
// for the host with the given serial number alongside the host's platform. The host must be DEP assigned to Fleet.
GetMDMAppleOSUpdatesSettingsByHostSerial(ctx context.Context, hostSerial string) (string, *AppleOSUpdateSettings, error)
// InsertMDMConfigAssets inserts MDM related config assets, such as SCEP and APNS certs and keys.
// tx is optional and can be used to pass an existing transaction.
InsertMDMConfigAssets(ctx context.Context, assets []MDMConfigAsset, tx sqlx.ExtContext) error
// InsertOrReplaceMDMConfigAsset inserts or updates an encrypted asset.
InsertOrReplaceMDMConfigAsset(ctx context.Context, asset MDMConfigAsset) error
// GetAllMDMConfigAssetsByName returns the requested config assets.
//
// If it doesn't find all the assets requested, it returns a `mysql.ErrPartialResult` error.
// The queryerContext is optional and can be used to pass a transaction.
GetAllMDMConfigAssetsByName(ctx context.Context, assetNames []MDMAssetName,
queryerContext sqlx.QueryerContext) (map[MDMAssetName]MDMConfigAsset, error)
// GetAllMDMConfigAssetsHashes behaves like
// GetAllMDMConfigAssetsByName, but only returns a sha256 checksum of
// each asset
//
// If it doesn't find all the assets requested, it returns a `mysql.ErrPartialResult`
GetAllMDMConfigAssetsHashes(ctx context.Context, assetNames []MDMAssetName) (map[MDMAssetName]string, error)
// DeleteMDMConfigAssetsByName soft deletes the given MDM config assets.
DeleteMDMConfigAssetsByName(ctx context.Context, assetNames []MDMAssetName) error
// HardDeleteMDMConfigAsset permanently deletes the given MDM config asset.
HardDeleteMDMConfigAsset(ctx context.Context, assetName MDMAssetName) error
// ReplaceMDMConfigAssets replaces (soft delete if they exist + insert) `MDMConfigAsset`s in a
// single transaction. Useful for "renew" flows where users are updating the assets with newly
// generated ones.
// tx parameter is optional and can be used to pass an existing transaction.
ReplaceMDMConfigAssets(ctx context.Context, assets []MDMConfigAsset, tx sqlx.ExtContext) error
// GetAllCAConfigAssetsByType returns the config assets for DigiCert and custom SCEP CAs.
GetAllCAConfigAssetsByType(ctx context.Context, assetType CAConfigAssetType) (map[string]CAConfigAsset, error)
GetCAConfigAsset(ctx context.Context, name string, assetType CAConfigAssetType) (*CAConfigAsset, error)
SaveCAConfigAssets(ctx context.Context, assets []CAConfigAsset) error
DeleteCAConfigAssets(ctx context.Context, names []string) error
@@ -2880,3 +2846,37 @@ type EntityUsingSecret struct {
// TeamName is the name of the team the entity belongs to.
TeamName string
}
type AccessesMDMConfigAssets interface {
// InsertMDMConfigAssets inserts MDM-related config assets, such as SCEP and APNS certs and keys.
// tx is used to pass an existing transaction; if nil, a new transaction will be created inside the call
InsertMDMConfigAssets(ctx context.Context, assets []MDMConfigAsset, tx sqlx.ExtContext) error
// InsertOrReplaceMDMConfigAsset inserts or updates an encrypted asset.
InsertOrReplaceMDMConfigAsset(ctx context.Context, asset MDMConfigAsset) error
// GetAllMDMConfigAssetsByName returns the requested config assets.
//
// If it doesn't find all the assets requested, it returns a `mysql.ErrPartialResult` error.
// The queryerContext is optional and can be used to pass a transaction.
GetAllMDMConfigAssetsByName(ctx context.Context, assetNames []MDMAssetName,
queryerContext sqlx.QueryerContext) (map[MDMAssetName]MDMConfigAsset, error)
// GetAllMDMConfigAssetsHashes behaves like
// GetAllMDMConfigAssetsByName, but only returns a sha256 checksum of
// each asset
//
// If it doesn't find all the assets requested, it returns a `mysql.ErrPartialResult`
GetAllMDMConfigAssetsHashes(ctx context.Context, assetNames []MDMAssetName) (map[MDMAssetName]string, error)
// DeleteMDMConfigAssetsByName soft deletes the given MDM config assets.
DeleteMDMConfigAssetsByName(ctx context.Context, assetNames []MDMAssetName) error
// HardDeleteMDMConfigAsset permanently deletes the given MDM config asset.
HardDeleteMDMConfigAsset(ctx context.Context, assetName MDMAssetName) error
// ReplaceMDMConfigAssets replaces (soft delete if they exist + insert) `MDMConfigAsset`s in a
// single transaction. Useful for "renew" flows where users are updating the assets with newly
// generated ones.
// tx parameter is optional and can be used to pass an existing transaction.
ReplaceMDMConfigAssets(ctx context.Context, assets []MDMConfigAsset, tx sqlx.ExtContext) error
// GetAllCAConfigAssetsByType returns the config assets for DigiCert and custom SCEP CAs.
GetAllCAConfigAssetsByType(ctx context.Context, assetType CAConfigAssetType) (map[string]CAConfigAsset, error)
}
type GetsAppConfig interface {
AppConfig(ctx context.Context) (*AppConfig, error)
}
+3
View File
@@ -920,6 +920,9 @@ const (
MDMAssetConditionalAccessIDPCert MDMAssetName = "conditional_access_idp_cert"
// MDMAssetConditionalAccessIDPKey is the private key Fleet uses to sign SAML assertions as an IdP for conditional access
MDMAssetConditionalAccessIDPKey MDMAssetName = "conditional_access_idp_key"
// MDMAssetVPPProxyBearerToken is the bearer token Fleet uses to communicate with the fleetdm.com VPP metadata proxy
MDMAssetVPPProxyBearerToken MDMAssetName = "vpp_proxy_bearer_token" //nolint:gosec // no, this is not a credential
)
type MDMConfigAsset struct {
+338
View File
@@ -0,0 +1,338 @@
package apple_apps
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/pkg/retry"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
)
type Metadata struct {
ID string `json:"id"`
Attributes Attributes `json:"attributes"`
}
type Attributes struct {
Name string `json:"name"`
Platforms map[string]PlatformData `json:"platformAttributes"`
DeviceFamilies []string `json:"deviceFamilies"`
}
type PlatformData struct {
Artwork ArtData `json:"artwork"`
BundleID string `json:"bundleId"`
ExternalVersionID uint `json:"externalVersionId"`
LatestVersionInfo LatestVersionInfo
}
func (d PlatformData) IconURL() string {
// using set values rather than artwork response values for consistency with previous impl
return strings.ReplaceAll(
strings.ReplaceAll(
strings.ReplaceAll(d.Artwork.TemplateURL, "{w}", "512"),
"{h}",
"512",
),
"{f}",
"png",
)
}
type LatestVersionInfo struct {
DisplayVersion string `json:"versionDisplay"`
}
type ArtData struct {
Height uint `json:"height"`
Width uint `json:"width"`
TemplateURL string `json:"url"`
}
type metadataResp struct {
Data []Metadata `json:"data"`
}
// Authenticator returns a bearer token for the VPP metadata service (proxied or direct), or an error if once can't be
// retrieved. If forceRenew is true, bypasses the database bearer token cache if it would've otherwise been used.
type Authenticator func(forceRenew bool) (string, error)
// client is a package-level client (similar to http.DefaultClient) so it can
// be reused instead of created as needed, as the internal Transport typically
// has internal state (cached connections, etc) and it's safe for concurrent
// use.
var client = fleethttp.NewClient(fleethttp.WithTimeout(10 * time.Second))
func GetMetadata(adamIDs []string, vppToken string, getBearerToken Authenticator) (map[string]Metadata, error) {
baseURL := getBaseURL()
reqURL, err := url.Parse(baseURL)
if err != nil {
return nil, fmt.Errorf("parsing base VPP app details URL: %w", err)
}
query := reqURL.Query()
query.Add("ids", strings.Join(adamIDs, ","))
reqURL.RawQuery = query.Encode()
req, err := http.NewRequest(http.MethodGet, reqURL.String(), nil)
if err != nil {
return nil, fmt.Errorf("creating request to VPP app details endpoint: %w", err)
}
// small max attempts count because in many cases we're calling this from a UI that does
// client-side retries on top of this
var bodyResp metadataResp
if err = retry.Do(
func() error { return do(req, vppToken, getBearerToken, false, &bodyResp) },
retry.WithInterval(time.Second),
retry.WithBackoffMultiplier(2),
retry.WithMaxAttempts(3),
retry.WithErrorFilter(func(err error) retry.ErrorOutcome {
// auth retries are handles inside do(); if we get all the way to the outer error,
// we've already tried to recover and should bail
if strings.Contains(err.Error(), "auth") {
return retry.ErrorOutcomeDoNotRetry
}
return retry.ErrorOutcomeNormalRetry
}),
); err != nil {
return nil, fmt.Errorf("retrieving asset metadata: %w", err)
}
metadata := make(map[string]Metadata)
for _, a := range bodyResp.Data {
metadata[fmt.Sprint(a.ID)] = a
}
return metadata, nil
}
func do(req *http.Request, vppToken string, getBearerToken Authenticator, forceRenew bool, dest *metadataResp) error {
bearerToken, err := getBearerToken(forceRenew)
if err != nil {
return fmt.Errorf("authenticating to VPP app details endpoint: %w", err)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", bearerToken))
req.Header.Set("Cookie", fmt.Sprintf("itvt=%s", vppToken))
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("making request to VPP app details endpoint: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("reading response body from VPP app details endpoint: %w", err)
}
if resp.StatusCode != http.StatusOK {
limitedBody := body
if len(limitedBody) > 1000 {
limitedBody = limitedBody[:1000]
}
if resp.StatusCode == http.StatusUnauthorized && !forceRenew {
return do(req, vppToken, getBearerToken, true, dest)
} else if resp.StatusCode >= http.StatusTooManyRequests && resp.Header.Get("Retry-After") != "" {
retryAfter := resp.Header.Get("Retry-After")
seconds, err := strconv.ParseInt(retryAfter, 10, 0)
if err != nil {
return fmt.Errorf("parsing retry-after header: %w", err)
}
ticker := time.NewTicker(time.Duration(seconds) * time.Second)
defer ticker.Stop()
<-ticker.C
return do(req, vppToken, getBearerToken, false, dest)
}
return fmt.Errorf("calling VPP app details endpoint failed with status %d: %s", resp.StatusCode, string(limitedBody))
}
if dest != nil {
if err := json.Unmarshal(body, dest); err != nil {
return fmt.Errorf("decoding response data from VPP app details endpoint: %w", err)
}
}
return nil
}
func ToVPPApps(app Metadata) map[fleet.InstallableDevicePlatform]fleet.VPPApp {
// length 1 because watchOS/tvOS/visionOS exist and we don't support them, so using the length of the DeviceFamilies
// slice would give us extra empty entries
platforms := make(map[fleet.InstallableDevicePlatform]fleet.VPPApp, 1)
for _, device := range app.Attributes.DeviceFamilies {
var (
data PlatformData
ok bool
platform fleet.InstallableDevicePlatform
)
// It is rare that a single app supports all platforms, but it is possible.
// Skipping the "appletvos" platform right now as we don't support tvOS;
// see https://github.com/DIYgod/RSSHub/blob/master/lib/routes/apple/apps.ts for mapping info
switch device {
case "iphone":
data, ok = app.Attributes.Platforms["ios"]
if !ok {
continue
}
platform = fleet.IOSPlatform
case "ipad":
data, ok = app.Attributes.Platforms["ios"]
if !ok {
continue
}
platform = fleet.IPadOSPlatform
case "mac":
data, ok = app.Attributes.Platforms["osx"]
if !ok {
continue
}
platform = fleet.MacOSPlatform
default:
continue
}
platforms[platform] = fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{
VPPAppID: fleet.VPPAppID{
AdamID: app.ID,
Platform: platform,
},
},
BundleIdentifier: data.BundleID,
IconURL: data.IconURL(),
Name: app.Attributes.Name,
LatestVersion: data.LatestVersionInfo.DisplayVersion,
}
}
return platforms
}
func getBaseURL() string {
region := "us"
if os.Getenv("FLEET_DEV_VPP_REGION") != "" {
region = os.Getenv("FLEET_DEV_VPP_REGION")
}
if os.Getenv("FLEET_DEV_STOKEN_AUTHENTICATED_APPS_URL") == "apple" {
return fmt.Sprintf("https://api.ent.apple.com/v1/catalog/%s/stoken-authenticated-apps?platform=iphone&additionalPlatforms=ipad,mac&extend[apps]=latestVersionInfo", region)
}
if os.Getenv("FLEET_DEV_STOKEN_AUTHENTICATED_APPS_URL") != "" {
return os.Getenv("FLEET_DEV_STOKEN_AUTHENTICATED_APPS_URL")
}
return fmt.Sprintf("https://fleetdm.com/api/vpp/v1/metadata/%s?platform=iphone&additionalPlatforms=ipad,mac&extend[apps]=latestVersionInfo", region)
}
type authResp struct {
Token string `json:"fleetServerSecret"`
}
type DataStore interface {
fleet.GetsAppConfig
fleet.AccessesMDMConfigAssets
}
func GetAuthenticator(ctx context.Context, ds DataStore, licenseKey string) Authenticator {
token := os.Getenv("FLEET_DEV_VPP_METADATA_BEARER_TOKEN")
if token != "" {
return func(bool) (string, error) { return token, nil }
}
return func(forceRenew bool) (string, error) {
const key = fleet.MDMAssetVPPProxyBearerToken
if !forceRenew {
// throwing away the error here as on retrieval errors we'll request a new token
fromDB, _ := ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{key}, nil)
if v, ok := fromDB[key]; ok {
return string(v.Value), nil
}
}
authUrl := os.Getenv("FLEET_DEV_VPP_PROXY_AUTH_URL")
if authUrl == "" {
authUrl = "https://fleetdm.com/api/vpp/v1/auth"
}
appConfig, err := ds.AppConfig(ctx)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "getting server URL from app config")
}
body, err := json.Marshal(struct {
ServerURL string `json:"serverUrl"`
}{appConfig.ServerSettings.ServerURL})
if err != nil {
return "", ctxerr.Wrap(ctx, err, "encoding authentication request for VPP metadata service")
}
req, err := http.NewRequestWithContext(ctx, "POST", authUrl, bytes.NewBuffer(body))
if err != nil {
return "", ctxerr.Wrap(ctx, err, "building authentication request for VPP metadata service")
}
var authResponse authResp
if err = doAuth(req, licenseKey, &authResponse); err != nil {
return "", ctxerr.Wrap(ctx, err, "authenticating to VPP metadata service")
}
if authResponse.Token == "" {
return "", ctxerr.New(ctx, "no access token received from VPP metadata service")
}
// no need to keep old access tokens around, but no need to hard-fail if we can't clean them up
_ = ds.HardDeleteMDMConfigAsset(ctx, key)
// don't fail if we can't persist the token; we can continue anyway and will try again with the next request
_ = ds.InsertOrReplaceMDMConfigAsset(ctx, fleet.MDMConfigAsset{Name: key, Value: []byte(authResponse.Token)})
return authResponse.Token, nil
}
}
func doAuth(req *http.Request, licenseKey string, dest *authResp) error {
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", licenseKey))
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("authenticating to VPP metadata service: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("reading authentication response from VPP metadata service: %w", err)
}
if resp.StatusCode != http.StatusOK {
limitedBody := body
if len(limitedBody) > 1000 {
limitedBody = limitedBody[:1000]
}
return fmt.Errorf("calling authentication endpoint for VPP metadata service failed with status %d: %s", resp.StatusCode, string(limitedBody))
}
if dest != nil {
if err := json.Unmarshal(body, dest); err != nil {
return fmt.Errorf("decoding response data from authentication endpoint for VPP metdata service: %w", err)
}
}
return nil
}
+499
View File
@@ -0,0 +1,499 @@
package apple_apps
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/require"
)
func TestGetBaseURL(t *testing.T) {
t.Run("Default URL", func(t *testing.T) {
os.Setenv("FLEET_DEV_STOKEN_AUTHENTICATED_APPS_URL", "")
require.Equal(t, "https://fleetdm.com/api/vpp/v1/metadata/us?platform=iphone&additionalPlatforms=ipad,mac&extend[apps]=latestVersionInfo", getBaseURL())
})
t.Run("Custom URL", func(t *testing.T) {
customURL := "http://localhost:8000"
os.Setenv("FLEET_DEV_STOKEN_AUTHENTICATED_APPS_URL", customURL)
require.Equal(t, customURL, getBaseURL())
})
t.Run("Custom Region", func(t *testing.T) {
os.Setenv("FLEET_DEV_STOKEN_AUTHENTICATED_APPS_URL", "")
os.Setenv("FLEET_DEV_VPP_REGION", "fr")
require.Equal(t, "https://fleetdm.com/api/vpp/v1/metadata/fr?platform=iphone&additionalPlatforms=ipad,mac&extend[apps]=latestVersionInfo", getBaseURL())
})
t.Run("Direct to Apple", func(t *testing.T) {
os.Setenv("FLEET_DEV_STOKEN_AUTHENTICATED_APPS_URL", "apple")
os.Setenv("FLEET_DEV_VPP_REGION", "")
require.Equal(t, "https://api.ent.apple.com/v1/catalog/us/stoken-authenticated-apps?platform=iphone&additionalPlatforms=ipad,mac&extend[apps]=latestVersionInfo", getBaseURL())
})
}
func setupFakeServer(t *testing.T, handler http.HandlerFunc) {
server := httptest.NewServer(handler)
os.Setenv("FLEET_DEV_STOKEN_AUTHENTICATED_APPS_URL", server.URL)
t.Cleanup(server.Close)
}
func TestGetMetadataRetries(t *testing.T) {
t.Run("successful on first attempt", func(t *testing.T) {
var callCount int
setupFakeServer(t, func(w http.ResponseWriter, r *http.Request) {
callCount++
w.WriteHeader(http.StatusOK)
resp := metadataResp{
Data: []Metadata{
{ID: "123", Attributes: Attributes{Name: "Test App"}},
},
}
_ = json.NewEncoder(w).Encode(resp)
})
result, err := GetMetadata([]string{"123"}, "vppToken", func(bool) (string, error) {
return "bearer-token", nil
})
require.NoError(t, err)
require.Equal(t, 1, callCount)
require.Len(t, result, 1)
require.Equal(t, "Test App", result["123"].Attributes.Name)
})
t.Run("retries on 500 error and succeeds", func(t *testing.T) {
var callCount int
setupFakeServer(t, func(w http.ResponseWriter, r *http.Request) {
callCount++
if callCount < 2 {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("server error"))
return
}
w.WriteHeader(http.StatusOK)
resp := metadataResp{
Data: []Metadata{
{ID: "456", Attributes: Attributes{Name: "Retry App"}},
},
}
_ = json.NewEncoder(w).Encode(resp)
})
result, err := GetMetadata([]string{"456"}, "vppToken", func(bool) (string, error) {
return "bearer-token", nil
})
require.NoError(t, err)
require.Equal(t, 2, callCount)
require.Len(t, result, 1)
require.Equal(t, "Retry App", result["456"].Attributes.Name)
})
t.Run("exhausts retries on persistent 500 error", func(t *testing.T) {
var callCount int
setupFakeServer(t, func(w http.ResponseWriter, r *http.Request) {
callCount++
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("persistent server error"))
})
_, err := GetMetadata([]string{"789"}, "vppToken", func(bool) (string, error) {
return "bearer-token", nil
})
require.Error(t, err)
require.Contains(t, err.Error(), "retrieving asset metadata")
// Should have retried 3 times (max attempts)
require.Equal(t, 3, callCount)
})
t.Run("does not retry on auth error", func(t *testing.T) {
var callCount int
setupFakeServer(t, func(w http.ResponseWriter, r *http.Request) {
callCount++
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte("unauthorized"))
})
_, err := GetMetadata([]string{"999"}, "vppToken", func(forceRenew bool) (string, error) {
// Always return the same token to simulate auth failure
return "invalid-token", nil
})
require.Error(t, err)
require.Contains(t, err.Error(), "auth")
// Should have called twice: initial + one retry with forceRenew, then bail
require.Equal(t, 2, callCount)
})
t.Run("returns multiple apps", func(t *testing.T) {
setupFakeServer(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
resp := metadataResp{
Data: []Metadata{
{ID: "111", Attributes: Attributes{Name: "App One"}},
{ID: "222", Attributes: Attributes{Name: "App Two"}},
{ID: "333", Attributes: Attributes{Name: "App Three"}},
},
}
_ = json.NewEncoder(w).Encode(resp)
})
result, err := GetMetadata([]string{"111", "222", "333"}, "vppToken", func(bool) (string, error) {
return "bearer-token", nil
})
require.NoError(t, err)
require.Len(t, result, 3)
require.Equal(t, "App One", result["111"].Attributes.Name)
require.Equal(t, "App Two", result["222"].Attributes.Name)
require.Equal(t, "App Three", result["333"].Attributes.Name)
})
}
// mockDataStore implements the DataStore interface for testing GetAuthenticator
type mockDataStore struct {
appConfig *fleet.AppConfig
appConfigErr error
assets map[fleet.MDMAssetName]fleet.MDMConfigAsset
getAssetsErr error
insertedAsset *fleet.MDMConfigAsset
hardDeletedAsset fleet.MDMAssetName
insertOrReplaceCalled bool
hardDeleteCalled bool
getAssetsByNameCalled bool
insertMDMConfigAssetsCalled bool
}
func (m *mockDataStore) AppConfig(ctx context.Context) (*fleet.AppConfig, error) {
return m.appConfig, m.appConfigErr
}
func (m *mockDataStore) InsertMDMConfigAssets(ctx context.Context, assets []fleet.MDMConfigAsset, tx sqlx.ExtContext) error {
m.insertMDMConfigAssetsCalled = true
return nil
}
func (m *mockDataStore) InsertOrReplaceMDMConfigAsset(ctx context.Context, asset fleet.MDMConfigAsset) error {
m.insertOrReplaceCalled = true
m.insertedAsset = &asset
return nil
}
func (m *mockDataStore) GetAllMDMConfigAssetsByName(ctx context.Context, assetNames []fleet.MDMAssetName, queryerContext sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
m.getAssetsByNameCalled = true
if m.getAssetsErr != nil {
return nil, m.getAssetsErr
}
return m.assets, nil
}
func (m *mockDataStore) GetAllMDMConfigAssetsHashes(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]string, error) {
return nil, nil
}
func (m *mockDataStore) DeleteMDMConfigAssetsByName(ctx context.Context, assetNames []fleet.MDMAssetName) error {
return nil
}
func (m *mockDataStore) HardDeleteMDMConfigAsset(ctx context.Context, assetName fleet.MDMAssetName) error {
m.hardDeleteCalled = true
m.hardDeletedAsset = assetName
return nil
}
func (m *mockDataStore) ReplaceMDMConfigAssets(ctx context.Context, assets []fleet.MDMConfigAsset, tx sqlx.ExtContext) error {
return nil
}
func (m *mockDataStore) GetAllCAConfigAssetsByType(ctx context.Context, assetType fleet.CAConfigAssetType) (map[string]fleet.CAConfigAsset, error) {
return nil, nil
}
func TestAuthentication(t *testing.T) {
// Clear any dev env vars that might interfere
originalDevToken := os.Getenv("FLEET_DEV_VPP_METADATA_BEARER_TOKEN")
originalAuthURL := os.Getenv("FLEET_DEV_VPP_PROXY_AUTH_URL")
t.Cleanup(func() {
os.Setenv("FLEET_DEV_VPP_METADATA_BEARER_TOKEN", originalDevToken)
os.Setenv("FLEET_DEV_VPP_PROXY_AUTH_URL", originalAuthURL)
})
t.Run("uses bearer token env var when set", func(t *testing.T) {
os.Setenv("FLEET_DEV_VPP_METADATA_BEARER_TOKEN", "dev-test-token")
defer os.Setenv("FLEET_DEV_VPP_METADATA_BEARER_TOKEN", "")
ds := &mockDataStore{}
auth := GetAuthenticator(context.Background(), ds, "license-key")
// Should return bearer token regardless of forceRenew
token, err := auth(false)
require.NoError(t, err)
require.Equal(t, "dev-test-token", token)
token, err = auth(true)
require.NoError(t, err)
require.Equal(t, "dev-test-token", token)
// Should not have accessed the datastore
require.False(t, ds.getAssetsByNameCalled)
})
t.Run("returns cached token from database when not forced renewal", func(t *testing.T) {
os.Setenv("FLEET_DEV_VPP_METADATA_BEARER_TOKEN", "")
ds := &mockDataStore{
assets: map[fleet.MDMAssetName]fleet.MDMConfigAsset{
fleet.MDMAssetVPPProxyBearerToken: {
Name: fleet.MDMAssetVPPProxyBearerToken,
Value: []byte("cached-token-from-db"),
},
},
}
auth := GetAuthenticator(context.Background(), ds, "license-key")
token, err := auth(false)
require.NoError(t, err)
require.Equal(t, "cached-token-from-db", token)
require.True(t, ds.getAssetsByNameCalled)
require.False(t, ds.insertOrReplaceCalled)
})
t.Run("requests new token when forced renewal even if cached exists", func(t *testing.T) {
os.Setenv("FLEET_DEV_VPP_METADATA_BEARER_TOKEN", "")
// Set up a mock auth server
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify the license key is in the Authorization header
require.Equal(t, "Bearer test-license-key", r.Header.Get("Authorization"))
// Verify the URL is set
body, err := io.ReadAll(r.Body)
require.NoError(t, err)
require.JSONEq(t, `{"serverUrl": "https://fleet.example.com"}`, string(body))
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"fleetServerSecret": "new-token-from-auth"}`))
}))
defer authServer.Close()
os.Setenv("FLEET_DEV_VPP_PROXY_AUTH_URL", authServer.URL)
ds := &mockDataStore{
assets: map[fleet.MDMAssetName]fleet.MDMConfigAsset{
fleet.MDMAssetVPPProxyBearerToken: {
Name: fleet.MDMAssetVPPProxyBearerToken,
Value: []byte("cached-token-from-db"),
},
},
appConfig: &fleet.AppConfig{
ServerSettings: fleet.ServerSettings{
ServerURL: "https://fleet.example.com",
},
},
}
auth := GetAuthenticator(context.Background(), ds, "test-license-key")
token, err := auth(true) // Force renewal
require.NoError(t, err)
require.Equal(t, "new-token-from-auth", token)
// Should not have checked DB since forceRenew=true
require.False(t, ds.getAssetsByNameCalled)
// Should have stored the new token
require.True(t, ds.insertOrReplaceCalled)
require.Equal(t, fleet.MDMAssetVPPProxyBearerToken, ds.insertedAsset.Name)
require.Equal(t, []byte("new-token-from-auth"), ds.insertedAsset.Value)
// Should have deleted the old token
require.True(t, ds.hardDeleteCalled)
require.Equal(t, fleet.MDMAssetVPPProxyBearerToken, ds.hardDeletedAsset)
})
t.Run("requests new token when nothing in database and no forced renewal", func(t *testing.T) {
os.Setenv("FLEET_DEV_VPP_METADATA_BEARER_TOKEN", "")
// Set up a mock auth server
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "Bearer my-license-key", r.Header.Get("Authorization"))
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"fleetServerSecret": "fresh-token"}`))
}))
defer authServer.Close()
os.Setenv("FLEET_DEV_VPP_PROXY_AUTH_URL", authServer.URL)
ds := &mockDataStore{
assets: map[fleet.MDMAssetName]fleet.MDMConfigAsset{}, // Empty - no cached token
appConfig: &fleet.AppConfig{
ServerSettings: fleet.ServerSettings{
ServerURL: "https://fleet.example.com",
},
},
}
auth := GetAuthenticator(context.Background(), ds, "my-license-key")
token, err := auth(false) // Not forced renewal, but no token in DB
require.NoError(t, err)
require.Equal(t, "fresh-token", token)
// Should have checked DB first
require.True(t, ds.getAssetsByNameCalled)
// Should have stored the new token
require.True(t, ds.insertOrReplaceCalled)
require.Equal(t, fleet.MDMAssetVPPProxyBearerToken, ds.insertedAsset.Name)
require.Equal(t, []byte("fresh-token"), ds.insertedAsset.Value)
})
t.Run("returns error when auth server fails", func(t *testing.T) {
os.Setenv("FLEET_DEV_VPP_METADATA_BEARER_TOKEN", "")
// Set up a mock auth server that fails
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error": "invalid license"}`))
}))
defer authServer.Close()
os.Setenv("FLEET_DEV_VPP_PROXY_AUTH_URL", authServer.URL)
ds := &mockDataStore{
assets: map[fleet.MDMAssetName]fleet.MDMConfigAsset{}, // Empty
appConfig: &fleet.AppConfig{
ServerSettings: fleet.ServerSettings{
ServerURL: "https://fleet.example.com",
},
},
}
auth := GetAuthenticator(context.Background(), ds, "bad-license-key")
_, err := auth(false)
require.Error(t, err)
require.Contains(t, err.Error(), "authenticating to VPP metadata service")
})
t.Run("returns error when auth response has empty token", func(t *testing.T) {
os.Setenv("FLEET_DEV_VPP_METADATA_BEARER_TOKEN", "")
// Set up a mock auth server that returns empty token
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"fleetServerSecret": ""}`))
}))
defer authServer.Close()
os.Setenv("FLEET_DEV_VPP_PROXY_AUTH_URL", authServer.URL)
ds := &mockDataStore{
assets: map[fleet.MDMAssetName]fleet.MDMConfigAsset{},
appConfig: &fleet.AppConfig{
ServerSettings: fleet.ServerSettings{
ServerURL: "https://fleet.example.com",
},
},
}
auth := GetAuthenticator(context.Background(), ds, "license-key")
_, err := auth(false)
require.Error(t, err)
require.Contains(t, err.Error(), "no access token received")
})
}
func TestDoRetries(t *testing.T) {
tests := []struct {
name string
handler http.HandlerFunc
wantCalls int
wantErr bool
wantMinTime time.Duration
}{
{
name: "success status code",
handler: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, err := w.Write([]byte("{}"))
require.NoError(t, err)
},
wantCalls: 1,
wantErr: false,
},
{
name: "bad requests no not retry (handled upstream)",
handler: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, err := w.Write([]byte("{}"))
require.NoError(t, err)
},
wantCalls: 1,
wantErr: true,
},
{
name: "500 requests does not retry (handled upstream)",
handler: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
_, err := w.Write([]byte("{}"))
require.NoError(t, err)
},
wantCalls: 1,
wantErr: true,
},
{
name: "auth fail makes another attempt",
handler: func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer foo" {
w.WriteHeader(http.StatusUnauthorized)
} else {
w.WriteHeader(http.StatusOK)
}
_, err := w.Write([]byte("{}"))
require.NoError(t, err)
},
wantCalls: 2,
wantErr: false,
},
{
name: "429 with retry-after header waits and retries",
handler: func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Retry-After", "1")
w.WriteHeader(http.StatusTooManyRequests)
_, err := w.Write([]byte("{}"))
require.NoError(t, err)
},
wantCalls: 3, // will return 429 2x, then return a blank success response
wantErr: false,
wantMinTime: 1 * time.Second,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var calls int
setupFakeServer(t, func(w http.ResponseWriter, r *http.Request) {
if calls < tt.wantCalls {
tt.handler(w, r)
calls++
} // default is a 200 response
})
start := time.Now()
req, err := http.NewRequest(http.MethodGet, os.Getenv("FLEET_DEV_STOKEN_AUTHENTICATED_APPS_URL"), nil)
require.NoError(t, err)
err = do(req, "vppToken", func(forceRenew bool) (string, error) {
if forceRenew {
return "foo", nil
}
return "", nil
}, false, nil)
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
require.Equal(t, tt.wantCalls, calls)
elapsed := time.Since(start)
require.WithinRange(t, time.Now(), start, start.Add(time.Duration(tt.wantCalls)*time.Second+tt.wantMinTime))
if tt.wantMinTime > 0 {
require.GreaterOrEqual(t, elapsed, tt.wantMinTime, "expected to wait at least %v for retry-after", tt.wantMinTime)
}
})
}
}
-117
View File
@@ -1,117 +0,0 @@
package itunes
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/pkg/retry"
)
type AssetMetadata struct {
BundleID string `json:"bundleId"`
ArtworkURL string `json:"artworkUrl512"`
Version string `json:"version"`
TrackName string `json:"trackName"`
TrackID uint `json:"trackId"`
SupportedDevices []string `json:"supportedDevices"`
}
type AssetMetadataFilter struct {
Entity string
}
// client is a package-level client (similar to http.DefaultClient) so it can
// be reused instead of created as needed, as the internal Transport typically
// has internal state (cached connections, etc) and it's safe for concurrent
// use.
var client = fleethttp.NewClient(fleethttp.WithTimeout(10 * time.Second))
func GetAssetMetadata(adamIDs []string, filter *AssetMetadataFilter) (map[string]AssetMetadata, error) {
baseURL := getBaseURL()
reqURL, err := url.Parse(baseURL)
if err != nil {
return nil, fmt.Errorf("parsing base iTunes URL: %w", err)
}
adamIDsParam := strings.Join(adamIDs, ",")
if filter != nil {
query := url.Values{}
query.Add("id", adamIDsParam)
query.Add("entity", filter.Entity)
reqURL.RawQuery = query.Encode()
}
req, err := http.NewRequest(http.MethodGet, reqURL.String(), nil)
if err != nil {
return nil, fmt.Errorf("creating request to Apple iTunes endpoint: %w", err)
}
var bodyResp struct {
Results []AssetMetadata `json:"results"`
}
if err = do(req, &bodyResp); err != nil {
return nil, fmt.Errorf("retrieving asset metadata: %w", err)
}
metadata := make(map[string]AssetMetadata)
for _, a := range bodyResp.Results {
metadata[fmt.Sprint(a.TrackID)] = a
}
return metadata, nil
}
func do[T any](req *http.Request, dest *T) error {
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("making request to Apple iTunes endpoint: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("reading response body from Apple iTunes endpoint: %w", err)
}
if resp.StatusCode != http.StatusOK {
limitedBody := body
if len(limitedBody) > 1000 {
limitedBody = limitedBody[:1000]
}
if resp.StatusCode >= http.StatusInternalServerError {
return retry.Do(
func() error { return do(req, dest) },
retry.WithInterval(1*time.Second),
retry.WithMaxAttempts(4),
)
}
return fmt.Errorf("calling Apple iTunes endpoint failed with status %d: %s", resp.StatusCode, string(limitedBody))
}
if dest != nil {
if err := json.Unmarshal(body, dest); err != nil {
return fmt.Errorf("decoding response data from Apple iTunes endpoint: %w", err)
}
}
return nil
}
func getBaseURL() string {
devURL := os.Getenv("FLEET_DEV_ITUNES_URL")
if devURL != "" {
return devURL
}
return "https://itunes.apple.com/lookup"
}
-90
View File
@@ -1,90 +0,0 @@
package itunes
import (
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestGetBaseURL(t *testing.T) {
t.Run("Default URL", func(t *testing.T) {
os.Setenv("FLEET_DEV_ITUNES_URL", "")
require.Equal(t, "https://itunes.apple.com/lookup", getBaseURL())
})
t.Run("Custom URL", func(t *testing.T) {
customURL := "http://localhost:8000"
os.Setenv("FLEET_DEV_ITUNES_URL", customURL)
require.Equal(t, customURL, getBaseURL())
})
}
func setupFakeServer(t *testing.T, handler http.HandlerFunc) {
server := httptest.NewServer(handler)
os.Setenv("FLEET_DEV_ITUNES_URL", server.URL)
t.Cleanup(server.Close)
}
func TestDoRetries(t *testing.T) {
tests := []struct {
name string
handler http.HandlerFunc
wantCalls int
wantErr bool
}{
{
name: "success status code",
handler: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, err := w.Write([]byte("{}"))
require.NoError(t, err)
},
wantCalls: 1,
wantErr: true,
},
{
name: "bad requests",
handler: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, err := w.Write([]byte("{}"))
require.NoError(t, err)
},
wantCalls: 1,
wantErr: true,
},
{
name: "500 requests retries",
handler: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
_, err := w.Write([]byte("{}"))
require.NoError(t, err)
},
wantCalls: 4,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var calls int
setupFakeServer(t, func(w http.ResponseWriter, r *http.Request) {
calls++
if calls < tt.wantCalls {
tt.handler(w, r)
return
}
})
start := time.Now()
req, err := http.NewRequest(http.MethodGet, os.Getenv("FLEET_DEV_ITUNES_URL"), nil)
require.NoError(t, err)
err = do[any](req, nil)
require.NoError(t, err)
require.Equal(t, tt.wantCalls, calls)
require.WithinRange(t, time.Now(), start, start.Add(time.Duration(tt.wantCalls)*time.Second))
})
}
}
+34 -13
View File
@@ -7,11 +7,11 @@ import (
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/apple/itunes"
"github.com/fleetdm/fleet/v4/server/mdm/apple/apple_apps"
)
// RefreshVersions updatest the LatestVersion fields for the VPP apps stored in Fleet.
func RefreshVersions(ctx context.Context, ds fleet.Datastore) error {
func RefreshVersions(ctx context.Context, ds fleet.Datastore, vppAuthenticator apple_apps.Authenticator) error {
apps, err := ds.GetAllVPPApps(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting all VPP apps")
@@ -24,35 +24,56 @@ func RefreshVersions(ctx context.Context, ds fleet.Datastore) error {
// We use a map for applications that share the same Adam ID for their iOS/iPadOS/macOS apps.
appsByAdamID := make(map[string][]*fleet.VPPApp) // Adam ID -> one, two, or three `*fleet.VPPApp`s.
// Gathering adamIDs on a set to deduplicate them and send them to iTunes.
// Gathering adamIDs on a set to deduplicate them and send them to Apple's Apps & Books API.
adamIDs := make(map[string]struct{})
for _, app := range apps {
adamIDs[app.AdamID] = struct{}{}
appsByAdamID[app.AdamID] = append(appsByAdamID[app.AdamID], app)
}
adamIDsToQueryITunes := slices.Collect(maps.Keys(adamIDs))
adamIDsToQuery := slices.Collect(maps.Keys(adamIDs))
meta, err := itunes.GetAssetMetadata(adamIDsToQueryITunes, &itunes.AssetMetadataFilter{Entity: "software"})
// in a multi-VPP-token environment, custom apps may be visible to one VPP token but not another;
// if you request apps that aren't visible from the Apple API the requests will take longer but
// will still return, so we can iterate through VPP tokens on hand until we have all apps enumerated
// to get the latest versions for each.
vppTokens, err := ds.ListVPPTokens(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting VPP app metadata from iTunes API")
return ctxerr.Wrap(ctx, err, "getting all VPP tokens")
}
retrievedApps := make(map[string]map[fleet.InstallableDevicePlatform]fleet.VPPApp)
var appsToUpdate []*fleet.VPPApp
for _, adamID := range adamIDsToQueryITunes {
if m, ok := meta[adamID]; ok {
// Iterate all platforms for the Adam ID (iOS/iPadOS/macOS).
for _, vppToken := range vppTokens {
meta, err := apple_apps.GetMetadata(adamIDsToQuery, vppToken.Token, vppAuthenticator)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting VPP app metadata from Apple API")
}
for k, v := range meta {
retrievedApps[k] = apple_apps.ToVPPApps(v)
}
// we found all apps, either because they are all public or because we've iterated over enough VPP keys
// to retrieve all custom apps; we don't need to request with any more apps
if len(retrievedApps) >= len(adamIDsToQuery) {
continue
}
}
for _, adamID := range adamIDsToQuery {
if retrievedByPlatform, ok := retrievedApps[adamID]; ok {
for _, app := range appsByAdamID[adamID] {
if m.Version != app.LatestVersion {
app.LatestVersion = m.Version
if current, ok := retrievedByPlatform[app.Platform]; ok && current.LatestVersion != app.LatestVersion {
app.LatestVersion = current.LatestVersion
appsToUpdate = append(appsToUpdate, app)
}
}
}
}
if len(appsToUpdate) == 0 {
// nothing to do
if len(appsToUpdate) == 0 { // nothing to do
return nil
}
+108 -108
View File
@@ -23,6 +23,24 @@ import (
var _ fleet.Datastore = (*DataStore)(nil)
type AppConfigFunc func(ctx context.Context) (*fleet.AppConfig, error)
type InsertMDMConfigAssetsFunc func(ctx context.Context, assets []fleet.MDMConfigAsset, tx sqlx.ExtContext) error
type InsertOrReplaceMDMConfigAssetFunc func(ctx context.Context, asset fleet.MDMConfigAsset) error
type GetAllMDMConfigAssetsByNameFunc func(ctx context.Context, assetNames []fleet.MDMAssetName, queryerContext sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error)
type GetAllMDMConfigAssetsHashesFunc func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]string, error)
type DeleteMDMConfigAssetsByNameFunc func(ctx context.Context, assetNames []fleet.MDMAssetName) error
type HardDeleteMDMConfigAssetFunc func(ctx context.Context, assetName fleet.MDMAssetName) error
type ReplaceMDMConfigAssetsFunc func(ctx context.Context, assets []fleet.MDMConfigAsset, tx sqlx.ExtContext) error
type GetAllCAConfigAssetsByTypeFunc func(ctx context.Context, assetType fleet.CAConfigAssetType) (map[string]fleet.CAConfigAsset, error)
type HealthCheckFunc func() error
type NewCarveFunc func(ctx context.Context, metadata *fleet.CarveMetadata) (*fleet.CarveMetadata, error)
@@ -363,8 +381,6 @@ type NewMFATokenFunc func(ctx context.Context, userID uint) (string, error)
type NewAppConfigFunc func(ctx context.Context, info *fleet.AppConfig) (*fleet.AppConfig, error)
type AppConfigFunc func(ctx context.Context) (*fleet.AppConfig, error)
type SaveAppConfigFunc func(ctx context.Context, info *fleet.AppConfig) error
type GetEnrollSecretsFunc func(ctx context.Context, teamID *uint) ([]*fleet.EnrollSecret, error)
@@ -1055,22 +1071,6 @@ type MDMAppleSetRemoveDeclarationsAsPendingFunc func(ctx context.Context, hostUU
type GetMDMAppleOSUpdatesSettingsByHostSerialFunc func(ctx context.Context, hostSerial string) (string, *fleet.AppleOSUpdateSettings, error)
type InsertMDMConfigAssetsFunc func(ctx context.Context, assets []fleet.MDMConfigAsset, tx sqlx.ExtContext) error
type InsertOrReplaceMDMConfigAssetFunc func(ctx context.Context, asset fleet.MDMConfigAsset) error
type GetAllMDMConfigAssetsByNameFunc func(ctx context.Context, assetNames []fleet.MDMAssetName, queryerContext sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error)
type GetAllMDMConfigAssetsHashesFunc func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]string, error)
type DeleteMDMConfigAssetsByNameFunc func(ctx context.Context, assetNames []fleet.MDMAssetName) error
type HardDeleteMDMConfigAssetFunc func(ctx context.Context, assetName fleet.MDMAssetName) error
type ReplaceMDMConfigAssetsFunc func(ctx context.Context, assets []fleet.MDMConfigAsset, tx sqlx.ExtContext) error
type GetAllCAConfigAssetsByTypeFunc func(ctx context.Context, assetType fleet.CAConfigAssetType) (map[string]fleet.CAConfigAsset, error)
type GetCAConfigAssetFunc func(ctx context.Context, name string, assetType fleet.CAConfigAssetType) (*fleet.CAConfigAsset, error)
type SaveCAConfigAssetsFunc func(ctx context.Context, assets []fleet.CAConfigAsset) error
@@ -1720,6 +1720,33 @@ type GetWindowsMDMCommandsForResendingFunc func(ctx context.Context, failedComma
type ResendWindowsMDMCommandFunc func(ctx context.Context, mdmDeviceId string, newCmd *fleet.MDMWindowsCommand, oldCmd *fleet.MDMWindowsCommand) error
type DataStore struct {
AppConfigFunc AppConfigFunc
AppConfigFuncInvoked bool
InsertMDMConfigAssetsFunc InsertMDMConfigAssetsFunc
InsertMDMConfigAssetsFuncInvoked bool
InsertOrReplaceMDMConfigAssetFunc InsertOrReplaceMDMConfigAssetFunc
InsertOrReplaceMDMConfigAssetFuncInvoked bool
GetAllMDMConfigAssetsByNameFunc GetAllMDMConfigAssetsByNameFunc
GetAllMDMConfigAssetsByNameFuncInvoked bool
GetAllMDMConfigAssetsHashesFunc GetAllMDMConfigAssetsHashesFunc
GetAllMDMConfigAssetsHashesFuncInvoked bool
DeleteMDMConfigAssetsByNameFunc DeleteMDMConfigAssetsByNameFunc
DeleteMDMConfigAssetsByNameFuncInvoked bool
HardDeleteMDMConfigAssetFunc HardDeleteMDMConfigAssetFunc
HardDeleteMDMConfigAssetFuncInvoked bool
ReplaceMDMConfigAssetsFunc ReplaceMDMConfigAssetsFunc
ReplaceMDMConfigAssetsFuncInvoked bool
GetAllCAConfigAssetsByTypeFunc GetAllCAConfigAssetsByTypeFunc
GetAllCAConfigAssetsByTypeFuncInvoked bool
HealthCheckFunc HealthCheckFunc
HealthCheckFuncInvoked bool
@@ -2230,9 +2257,6 @@ type DataStore struct {
NewAppConfigFunc NewAppConfigFunc
NewAppConfigFuncInvoked bool
AppConfigFunc AppConfigFunc
AppConfigFuncInvoked bool
SaveAppConfigFunc SaveAppConfigFunc
SaveAppConfigFuncInvoked bool
@@ -3268,30 +3292,6 @@ type DataStore struct {
GetMDMAppleOSUpdatesSettingsByHostSerialFunc GetMDMAppleOSUpdatesSettingsByHostSerialFunc
GetMDMAppleOSUpdatesSettingsByHostSerialFuncInvoked bool
InsertMDMConfigAssetsFunc InsertMDMConfigAssetsFunc
InsertMDMConfigAssetsFuncInvoked bool
InsertOrReplaceMDMConfigAssetFunc InsertOrReplaceMDMConfigAssetFunc
InsertOrReplaceMDMConfigAssetFuncInvoked bool
GetAllMDMConfigAssetsByNameFunc GetAllMDMConfigAssetsByNameFunc
GetAllMDMConfigAssetsByNameFuncInvoked bool
GetAllMDMConfigAssetsHashesFunc GetAllMDMConfigAssetsHashesFunc
GetAllMDMConfigAssetsHashesFuncInvoked bool
DeleteMDMConfigAssetsByNameFunc DeleteMDMConfigAssetsByNameFunc
DeleteMDMConfigAssetsByNameFuncInvoked bool
HardDeleteMDMConfigAssetFunc HardDeleteMDMConfigAssetFunc
HardDeleteMDMConfigAssetFuncInvoked bool
ReplaceMDMConfigAssetsFunc ReplaceMDMConfigAssetsFunc
ReplaceMDMConfigAssetsFuncInvoked bool
GetAllCAConfigAssetsByTypeFunc GetAllCAConfigAssetsByTypeFunc
GetAllCAConfigAssetsByTypeFuncInvoked bool
GetCAConfigAssetFunc GetCAConfigAssetFunc
GetCAConfigAssetFuncInvoked bool
@@ -4267,6 +4267,69 @@ type DataStore struct {
mu sync.Mutex
}
func (s *DataStore) AppConfig(ctx context.Context) (*fleet.AppConfig, error) {
s.mu.Lock()
s.AppConfigFuncInvoked = true
s.mu.Unlock()
return s.AppConfigFunc(ctx)
}
func (s *DataStore) InsertMDMConfigAssets(ctx context.Context, assets []fleet.MDMConfigAsset, tx sqlx.ExtContext) error {
s.mu.Lock()
s.InsertMDMConfigAssetsFuncInvoked = true
s.mu.Unlock()
return s.InsertMDMConfigAssetsFunc(ctx, assets, tx)
}
func (s *DataStore) InsertOrReplaceMDMConfigAsset(ctx context.Context, asset fleet.MDMConfigAsset) error {
s.mu.Lock()
s.InsertOrReplaceMDMConfigAssetFuncInvoked = true
s.mu.Unlock()
return s.InsertOrReplaceMDMConfigAssetFunc(ctx, asset)
}
func (s *DataStore) GetAllMDMConfigAssetsByName(ctx context.Context, assetNames []fleet.MDMAssetName, queryerContext sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
s.mu.Lock()
s.GetAllMDMConfigAssetsByNameFuncInvoked = true
s.mu.Unlock()
return s.GetAllMDMConfigAssetsByNameFunc(ctx, assetNames, queryerContext)
}
func (s *DataStore) GetAllMDMConfigAssetsHashes(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]string, error) {
s.mu.Lock()
s.GetAllMDMConfigAssetsHashesFuncInvoked = true
s.mu.Unlock()
return s.GetAllMDMConfigAssetsHashesFunc(ctx, assetNames)
}
func (s *DataStore) DeleteMDMConfigAssetsByName(ctx context.Context, assetNames []fleet.MDMAssetName) error {
s.mu.Lock()
s.DeleteMDMConfigAssetsByNameFuncInvoked = true
s.mu.Unlock()
return s.DeleteMDMConfigAssetsByNameFunc(ctx, assetNames)
}
func (s *DataStore) HardDeleteMDMConfigAsset(ctx context.Context, assetName fleet.MDMAssetName) error {
s.mu.Lock()
s.HardDeleteMDMConfigAssetFuncInvoked = true
s.mu.Unlock()
return s.HardDeleteMDMConfigAssetFunc(ctx, assetName)
}
func (s *DataStore) ReplaceMDMConfigAssets(ctx context.Context, assets []fleet.MDMConfigAsset, tx sqlx.ExtContext) error {
s.mu.Lock()
s.ReplaceMDMConfigAssetsFuncInvoked = true
s.mu.Unlock()
return s.ReplaceMDMConfigAssetsFunc(ctx, assets, tx)
}
func (s *DataStore) GetAllCAConfigAssetsByType(ctx context.Context, assetType fleet.CAConfigAssetType) (map[string]fleet.CAConfigAsset, error) {
s.mu.Lock()
s.GetAllCAConfigAssetsByTypeFuncInvoked = true
s.mu.Unlock()
return s.GetAllCAConfigAssetsByTypeFunc(ctx, assetType)
}
func (s *DataStore) HealthCheck() error {
s.mu.Lock()
s.HealthCheckFuncInvoked = true
@@ -5457,13 +5520,6 @@ func (s *DataStore) NewAppConfig(ctx context.Context, info *fleet.AppConfig) (*f
return s.NewAppConfigFunc(ctx, info)
}
func (s *DataStore) AppConfig(ctx context.Context) (*fleet.AppConfig, error) {
s.mu.Lock()
s.AppConfigFuncInvoked = true
s.mu.Unlock()
return s.AppConfigFunc(ctx)
}
func (s *DataStore) SaveAppConfig(ctx context.Context, info *fleet.AppConfig) error {
s.mu.Lock()
s.SaveAppConfigFuncInvoked = true
@@ -7879,62 +7935,6 @@ func (s *DataStore) GetMDMAppleOSUpdatesSettingsByHostSerial(ctx context.Context
return s.GetMDMAppleOSUpdatesSettingsByHostSerialFunc(ctx, hostSerial)
}
func (s *DataStore) InsertMDMConfigAssets(ctx context.Context, assets []fleet.MDMConfigAsset, tx sqlx.ExtContext) error {
s.mu.Lock()
s.InsertMDMConfigAssetsFuncInvoked = true
s.mu.Unlock()
return s.InsertMDMConfigAssetsFunc(ctx, assets, tx)
}
func (s *DataStore) InsertOrReplaceMDMConfigAsset(ctx context.Context, asset fleet.MDMConfigAsset) error {
s.mu.Lock()
s.InsertOrReplaceMDMConfigAssetFuncInvoked = true
s.mu.Unlock()
return s.InsertOrReplaceMDMConfigAssetFunc(ctx, asset)
}
func (s *DataStore) GetAllMDMConfigAssetsByName(ctx context.Context, assetNames []fleet.MDMAssetName, queryerContext sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
s.mu.Lock()
s.GetAllMDMConfigAssetsByNameFuncInvoked = true
s.mu.Unlock()
return s.GetAllMDMConfigAssetsByNameFunc(ctx, assetNames, queryerContext)
}
func (s *DataStore) GetAllMDMConfigAssetsHashes(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]string, error) {
s.mu.Lock()
s.GetAllMDMConfigAssetsHashesFuncInvoked = true
s.mu.Unlock()
return s.GetAllMDMConfigAssetsHashesFunc(ctx, assetNames)
}
func (s *DataStore) DeleteMDMConfigAssetsByName(ctx context.Context, assetNames []fleet.MDMAssetName) error {
s.mu.Lock()
s.DeleteMDMConfigAssetsByNameFuncInvoked = true
s.mu.Unlock()
return s.DeleteMDMConfigAssetsByNameFunc(ctx, assetNames)
}
func (s *DataStore) HardDeleteMDMConfigAsset(ctx context.Context, assetName fleet.MDMAssetName) error {
s.mu.Lock()
s.HardDeleteMDMConfigAssetFuncInvoked = true
s.mu.Unlock()
return s.HardDeleteMDMConfigAssetFunc(ctx, assetName)
}
func (s *DataStore) ReplaceMDMConfigAssets(ctx context.Context, assets []fleet.MDMConfigAsset, tx sqlx.ExtContext) error {
s.mu.Lock()
s.ReplaceMDMConfigAssetsFuncInvoked = true
s.mu.Unlock()
return s.ReplaceMDMConfigAssetsFunc(ctx, assets, tx)
}
func (s *DataStore) GetAllCAConfigAssetsByType(ctx context.Context, assetType fleet.CAConfigAssetType) (map[string]fleet.CAConfigAsset, error) {
s.mu.Lock()
s.GetAllCAConfigAssetsByTypeFuncInvoked = true
s.mu.Unlock()
return s.GetAllCAConfigAssetsByTypeFunc(ctx, assetType)
}
func (s *DataStore) GetCAConfigAsset(ctx context.Context, name string, assetType fleet.CAConfigAssetType) (*fleet.CAConfigAsset, error) {
s.mu.Lock()
s.GetCAConfigAssetFuncInvoked = true
@@ -1351,14 +1351,14 @@ func (s *integrationMDMTestSuite) TestSetupExperienceWithLotsOfVPPApps() {
ctx := context.Background()
s.setSkipWorkerJobs(t)
s.registerResetITunesData(t)
s.registerResetVPPProxyData(t)
// Set up some additional VPP apps on the mock Apple servers
s.appleITunesSrvData["6"] = `{"bundleId": "f-6", "artworkUrl512": "https://example.com/images/6", "version": "6.0.0", "trackName": "App 6", "TrackID": 6}`
s.appleITunesSrvData["7"] = `{"bundleId": "g-7", "artworkUrl512": "https://example.com/images/7", "version": "7.0.0", "trackName": "App 7", "TrackID": 7}`
s.appleITunesSrvData["8"] = `{"bundleId": "h-8", "artworkUrl512": "https://example.com/images/8", "version": "8.0.0", "trackName": "App 8", "TrackID": 8}`
s.appleITunesSrvData["9"] = `{"bundleId": "i-9", "artworkUrl512": "https://example.com/images/9", "version": "9.0.0", "trackName": "App 9", "TrackID": 9}`
s.appleITunesSrvData["10"] = `{"bundleId": "j-10", "artworkUrl512": "https://example.com/images/10", "version": "10.0.0", "trackName": "App 10", "TrackID": 10}`
// Set up some additional VPP apps on the mock the Fleet proxy to Apple servers
s.appleVPPProxySrvData["6"] = `{"id": "6", "attributes": {"name": "App 6", "platformAttributes": {"osx": {"bundleId": "f-6", "artwork": {"url": "https://example.com/images/6/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "6.0.0"}}}, "deviceFamilies": ["mac"]}}`
s.appleVPPProxySrvData["7"] = `{"id": "7", "attributes": {"name": "App 7", "platformAttributes": {"osx": {"bundleId": "g-7", "artwork": {"url": "https://example.com/images/7/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "7.0.0"}}}, "deviceFamilies": ["mac"]}}`
s.appleVPPProxySrvData["8"] = `{"id": "8", "attributes": {"name": "App 8", "platformAttributes": {"osx": {"bundleId": "h-8", "artwork": {"url": "https://example.com/images/8/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "8.0.0"}}}, "deviceFamilies": ["mac"]}}`
s.appleVPPProxySrvData["9"] = `{"id": "9", "attributes": {"name": "App 9", "platformAttributes": {"osx": {"bundleId": "i-9", "artwork": {"url": "https://example.com/images/9/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "9.0.0"}}}, "deviceFamilies": ["mac"]}}`
s.appleVPPProxySrvData["10"] = `{"id": "10", "attributes": {"name": "App 10", "platformAttributes": {"osx": {"bundleId": "j-10", "artwork": {"url": "https://example.com/images/10/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "10.0.0"}}}, "deviceFamilies": ["mac"]}}`
s.appleVPPConfigSrvConfig.Assets = append(s.appleVPPConfigSrvConfig.Assets, []vpp.Asset{
{
+78 -74
View File
@@ -123,8 +123,8 @@ type integrationMDMTestSuite struct {
scepChallenge string
appleVPPConfigSrv *httptest.Server
appleVPPConfigSrvConfig *appleVPPConfigSrvConf
appleITunesSrv *httptest.Server
appleITunesSrvData map[string]string
appleVPPProxySrv *httptest.Server
appleVPPProxySrvData map[string]string
appleGDMFSrv *httptest.Server
mockedDownloadFleetdmMeta fleetdbase.Metadata
scepConfig *eeservice.SCEPConfigService
@@ -646,35 +646,34 @@ func (s *integrationMDMTestSuite) SetupSuite() {
_, _ = w.Write(resp)
}))
s.appleITunesSrvData = map[string]string{
// deviceFamilies: "mac" -> osx platform, "iphone" -> ios platform, "ipad" -> ios platform
s.appleVPPProxySrvData = map[string]string{
// macOS app
"1": `{"bundleId": "a-1", "artworkUrl512": "https://example.com/images/1", "version": "1.0.0", "trackName": "App 1", "TrackID": 1}`,
"1": `{"id": "1", "attributes": {"name": "App 1", "platformAttributes": {"osx": {"bundleId": "a-1", "artwork": {"url": "https://example.com/images/1/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "1.0.0"}}}, "deviceFamilies": ["mac"]}}`,
// macOS, iOS, iPadOS app
"2": `{"bundleId": "b-2", "artworkUrl512": "https://example.com/images/2", "version": "2.0.0", "trackName": "App 2", "TrackID": 2, "supportedDevices": ["MacDesktop-MacDesktop", "iPhone5s-iPhone5s", "iPadAir-iPadAir"] }`,
"2": `{"id": "2", "attributes": {"name": "App 2", "platformAttributes": {"osx": {"bundleId": "b-2", "artwork": {"url": "https://example.com/images/2/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "2.0.1"}}, "ios": {"bundleId": "b-2", "artwork": {"url": "https://example.com/images/2/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "2.0.0"}}}, "deviceFamilies": ["mac", "iphone", "ipad"]}}`,
// iPadOS app
"3": `{"bundleId": "c-3", "artworkUrl512": "https://example.com/images/3", "version": "3.0.0", "trackName": "App 3", "TrackID": 3, "supportedDevices": ["iPadAir-iPadAir"] }`,
"4": `{"bundleId": "d-4", "artworkUrl512": "https://example.com/images/4", "version": "4.0.0", "trackName": "App 4", "TrackID": 4}`,
// App with 0 licenses
"5": `{"bundleId": "e-5", "artworkUrl512": "https://example.com/images/5", "version": "5.0.0", "trackName": "App 5", "TrackID": 5}`,
"3": `{"id": "3", "attributes": {"name": "App 3", "platformAttributes": {"ios": {"bundleId": "c-3", "artwork": {"url": "https://example.com/images/3/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "3.0.0"}}}, "deviceFamilies": ["ipad"]}}`,
// macOS app
"4": `{"id": "4", "attributes": {"name": "App 4", "platformAttributes": {"osx": {"bundleId": "d-4", "artwork": {"url": "https://example.com/images/4/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "4.0.0"}}}, "deviceFamilies": ["mac"]}}`,
// App with 0 licenses - macOS app
"5": `{"id": "5", "attributes": {"name": "App 5", "platformAttributes": {"osx": {"bundleId": "e-5", "artwork": {"url": "https://example.com/images/5/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "5.0.0"}}}, "deviceFamilies": ["mac"]}}`,
}
s.appleITunesSrv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// a map of apps we can respond with
adamIDString := r.URL.Query().Get("id")
s.appleVPPProxySrv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
adamIDString := r.URL.Query().Get("ids")
adamIDs := strings.Split(adamIDString, ",")
var objs []string
for _, a := range adamIDs {
data, ok := s.appleITunesSrvData[a]
data, ok := s.appleVPPProxySrvData[a]
if !ok {
continue
}
objs = append(objs, data)
}
_, _ = w.Write([]byte(fmt.Sprintf(`{"results": [%s]}`, strings.Join(objs, ","))))
_, _ = w.Write(fmt.Appendf(nil, `{"data": [%s]}`, strings.Join(objs, ",")))
}))
s.appleGDMFSrv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -688,7 +687,9 @@ func (s *integrationMDMTestSuite) SetupSuite() {
s.T().Setenv("FLEET_DEV_GDMF_URL", s.appleGDMFSrv.URL)
s.T().Setenv("TEST_FLEETDM_API_URL", fleetdmSrv.URL)
s.T().Setenv("FLEET_DEV_ITUNES_URL", s.appleITunesSrv.URL)
s.T().Setenv("FLEET_DEV_STOKEN_AUTHENTICATED_APPS_URL", s.appleVPPProxySrv.URL)
// Set a static bearer token so the authenticator doesn't try to call an auth endpoint (tested elsewhere)
s.T().Setenv("FLEET_DEV_VPP_METADATA_BEARER_TOKEN", "test-bearer-token")
s.mockedDownloadFleetdmMeta = fleetdbase.Metadata{
MSIURL: fmt.Sprintf("https://download-testing.fleetdm.com/archive/stable/%s/fleetd-base.msi", uuid.NewString()),
@@ -728,7 +729,7 @@ func (s *integrationMDMTestSuite) SetupSuite() {
s.T().Cleanup(fleetdmSrv.Close)
s.T().Cleanup(s.appleVPPConfigSrv.Close)
s.T().Cleanup(s.appleITunesSrv.Close)
s.T().Cleanup(s.appleVPPProxySrv.Close)
s.T().Cleanup(s.appleGDMFSrv.Close)
}
@@ -12428,7 +12429,7 @@ func (s *integrationMDMTestSuite) TestVPPApps() {
},
Name: "App 1",
BundleIdentifier: "a-1",
IconURL: "https://example.com/images/1",
IconURL: "https://example.com/images/1/512x512.png",
LatestVersion: "1.0.0",
}
@@ -12471,7 +12472,7 @@ func (s *integrationMDMTestSuite) TestVPPApps() {
},
Name: "App 2",
BundleIdentifier: "b-2",
IconURL: "https://example.com/images/1",
IconURL: "https://example.com/images/1/512x512.png",
LatestVersion: "1.0.0",
}
@@ -12548,7 +12549,7 @@ func (s *integrationMDMTestSuite) TestVPPApps() {
require.False(t, updateAppResp.AppStoreApp.SelfService)
require.Equal(t, fleet.MacOSPlatform, updateAppResp.AppStoreApp.Platform)
activityData = `{"team_name": "%s", "software_title": "%s", "app_store_id": "%s", "software_icon_url": "https://example.com/images/2", "team_id": %d, "software_title_id": %d, "platform": "%s", "self_service": false, "labels_include_any": [{"id": %d, "name": %q}], "software_display_name": ""}`
activityData = `{"team_name": "%s", "software_title": "%s", "app_store_id": "%s", "software_icon_url": "https://example.com/images/2/512x512.png", "team_id": %d, "software_title_id": %d, "platform": "%s", "self_service": false, "labels_include_any": [{"id": %d, "name": %q}], "software_display_name": ""}`
s.lastActivityMatches(fleet.ActivityEditedAppStoreApp{}.ActivityName(),
fmt.Sprintf(activityData, team.Name,
excludeAnyApp.Name, excludeAnyApp.AdamID, team.ID, titleID, excludeAnyApp.Platform, l2.ID, l2.Name), 0)
@@ -12579,7 +12580,7 @@ func (s *integrationMDMTestSuite) TestVPPApps() {
// delete the VPP app
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleID), nil, http.StatusNoContent, "team_id", fmt.Sprintf("%d", team.ID))
activityData = `{"team_name": "%s", "software_title": "%s", "app_store_id": "%s", "software_icon_url": "https://example.com/images/2", "team_id": %d, "platform": "%s", "labels_include_any": [{"id": %d, "name": %q}]}`
activityData = `{"team_name": "%s", "software_title": "%s", "app_store_id": "%s", "software_icon_url": "https://example.com/images/2/512x512.png", "team_id": %d, "platform": "%s", "labels_include_any": [{"id": %d, "name": %q}]}`
s.lastActivityMatches(fleet.ActivityDeletedAppStoreApp{}.ActivityName(),
fmt.Sprintf(activityData, team.Name,
excludeAnyApp.Name, excludeAnyApp.AdamID, team.ID, excludeAnyApp.Platform, l2.ID, l2.Name), 0)
@@ -12595,7 +12596,7 @@ func (s *integrationMDMTestSuite) TestVPPApps() {
},
Name: "App 2",
BundleIdentifier: "b-2",
IconURL: "https://example.com/images/2",
IconURL: "https://example.com/images/2/512x512.png",
LatestVersion: "2.0.0",
}
@@ -12608,7 +12609,7 @@ func (s *integrationMDMTestSuite) TestVPPApps() {
},
Name: "App 2",
BundleIdentifier: "b-2",
IconURL: "https://example.com/images/2",
IconURL: "https://example.com/images/2/512x512.png",
LatestVersion: "2.0.0",
}
@@ -12770,7 +12771,7 @@ func (s *integrationMDMTestSuite) TestVPPApps() {
},
Name: "App 1",
BundleIdentifier: "a-1",
IconURL: "https://example.com/images/1",
IconURL: "https://example.com/images/1/512x512.png",
LatestVersion: "1.0.0",
}
iPadOSApp := fleet.VPPApp{
@@ -12782,7 +12783,7 @@ func (s *integrationMDMTestSuite) TestVPPApps() {
},
Name: "App 2",
BundleIdentifier: "b-2",
IconURL: "https://example.com/images/2",
IconURL: "https://example.com/images/2/512x512.png",
LatestVersion: "2.0.0",
}
iOSApp := fleet.VPPApp{
@@ -12794,7 +12795,7 @@ func (s *integrationMDMTestSuite) TestVPPApps() {
},
Name: "App 2",
BundleIdentifier: "b-2",
IconURL: "https://example.com/images/2",
IconURL: "https://example.com/images/2/512x512.png",
LatestVersion: "2.0.0",
}
expectedApps := []*fleet.VPPApp{
@@ -12810,8 +12811,8 @@ func (s *integrationMDMTestSuite) TestVPPApps() {
},
Name: "App 2",
BundleIdentifier: "b-2",
IconURL: "https://example.com/images/2",
LatestVersion: "2.0.0",
IconURL: "https://example.com/images/2/512x512.png",
LatestVersion: "2.0.1",
},
{
VPPAppTeam: fleet.VPPAppTeam{
@@ -12822,7 +12823,7 @@ func (s *integrationMDMTestSuite) TestVPPApps() {
},
Name: "App 3",
BundleIdentifier: "c-3",
IconURL: "https://example.com/images/3",
IconURL: "https://example.com/images/3/512x512.png",
LatestVersion: "3.0.0",
},
}
@@ -12877,7 +12878,7 @@ func (s *integrationMDMTestSuite) TestVPPApps() {
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", macOSTitleID), nil, http.StatusNoContent,
"team_id", fmt.Sprint(team.ID))
s.lastActivityMatches(fleet.ActivityDeletedAppStoreApp{}.ActivityName(),
fmt.Sprintf(`{"team_name": "%s", "software_title": "%s", "app_store_id": "%s", "software_icon_url": "https://example.com/images/1", "team_id": %d, "platform": "%s"}`, team.Name,
fmt.Sprintf(`{"team_name": "%s", "software_title": "%s", "app_store_id": "%s", "software_icon_url": "https://example.com/images/1/512x512.png", "team_id": %d, "platform": "%s"}`, team.Name,
addedApp.Name, addedApp.AdamID, team.ID, addedApp.Platform), 0)
// deleting it again fails, not found
@@ -13551,7 +13552,7 @@ func (s *integrationMDMTestSuite) TestNoTeamVPPAppIcons() {
},
Name: "App 1",
BundleIdentifier: "a-1",
IconURL: "https://example.com/images/1",
IconURL: "https://example.com/images/1/512x512.png",
LatestVersion: "1.0.0",
}
@@ -13684,7 +13685,7 @@ func (s *integrationMDMTestSuite) TestVPPAppPolicyAutomation() {
},
Name: "App 1",
BundleIdentifier: "a-1",
IconURL: "https://example.com/images/1",
IconURL: "https://example.com/images/1/512x512.png",
LatestVersion: "1.0.0",
}
iPadOSApp := fleet.VPPApp{
@@ -13696,7 +13697,7 @@ func (s *integrationMDMTestSuite) TestVPPAppPolicyAutomation() {
},
Name: "App 2",
BundleIdentifier: "b-2",
IconURL: "https://example.com/images/2",
IconURL: "https://example.com/images/2/512x512.png",
LatestVersion: "2.0.0",
}
iOSApp := fleet.VPPApp{
@@ -13708,7 +13709,7 @@ func (s *integrationMDMTestSuite) TestVPPAppPolicyAutomation() {
},
Name: "App 2",
BundleIdentifier: "b-2",
IconURL: "https://example.com/images/2",
IconURL: "https://example.com/images/2/512x512.png",
LatestVersion: "2.0.0",
}
expectedApps := []*fleet.VPPApp{
@@ -13724,8 +13725,8 @@ func (s *integrationMDMTestSuite) TestVPPAppPolicyAutomation() {
},
Name: "App 2",
BundleIdentifier: "b-2",
IconURL: "https://example.com/images/2",
LatestVersion: "2.0.0",
IconURL: "https://example.com/images/2/512x512.png",
LatestVersion: "2.0.1", // different version for macOS
},
{
VPPAppTeam: fleet.VPPAppTeam{
@@ -13736,7 +13737,7 @@ func (s *integrationMDMTestSuite) TestVPPAppPolicyAutomation() {
},
Name: "App 3",
BundleIdentifier: "c-3",
IconURL: "https://example.com/images/3",
IconURL: "https://example.com/images/3/512x512.png",
LatestVersion: "3.0.0",
},
}
@@ -17450,30 +17451,32 @@ func (s *integrationMDMTestSuite) TestVPPPolicyAutomationLabelScopingRetrigger()
require.Equal(t, uint(1), policy1.FailingHostCount)
}
// registerResetITunesData resets the iTunes data after tests in `t` complete.
func (s *integrationMDMTestSuite) registerResetITunesData(t *testing.T) {
oldApps := s.appleITunesSrvData
t.Cleanup(func() { s.appleITunesSrvData = oldApps })
// registerResetVPPProxyData resets the VPP proxy data after tests in `t` complete.
func (s *integrationMDMTestSuite) registerResetVPPProxyData(t *testing.T) {
oldApps := s.appleVPPProxySrvData
t.Cleanup(func() { s.appleVPPProxySrvData = oldApps })
}
func (s *integrationMDMTestSuite) TestRefreshVPPAppVersions() {
t := s.T()
ctx := context.Background()
// Reset the iTunes data to what it was before this test
s.registerResetITunesData(t)
// Reset the VPP proxy data to what it was before this test
s.registerResetVPPProxyData(t)
// Set up 3 apps - macOS, iOS, and iPadOS
s.appleITunesSrvData = map[string]string{
"1": `{"bundleId": "a-1", "artworkUrl512": "https://example.com/images/1", "version": "1.0.0", "trackName": "App 1", "TrackID": 1}`,
"2": `{"bundleId": "d-2", "artworkUrl512": "https://example.com/images/2", "version": "2.0.0", "trackName": "App 2", "TrackID": 2, "supportedDevices": ["iPhone5s-iPhone5s"] }`,
"3": `{"bundleId": "b-3", "artworkUrl512": "https://example.com/images/3", "version": "3.0.0", "trackName": "App 3", "TrackID": 3, "supportedDevices": ["iPadAir-iPadAir"] }`,
// Set up 3 apps - macOS, iOS, and iPadOS (using new VPP proxy format)
s.appleVPPProxySrvData = map[string]string{
"1": `{"id": "1", "attributes": {"name": "App 1", "platformAttributes": {"osx": {"bundleId": "a-1", "artwork": {"url": "https://example.com/images/1/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "1.0.0"}}}, "deviceFamilies": ["mac"]}}`,
"2": `{"id": "2", "attributes": {"name": "App 2", "platformAttributes": {"ios": {"bundleId": "d-2", "artwork": {"url": "https://example.com/images/2/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "2.0.0"}}}, "deviceFamilies": ["iphone"]}}`,
"3": `{"id": "3", "attributes": {"name": "App 3", "platformAttributes": {"ios": {"bundleId": "b-3", "artwork": {"url": "https://example.com/images/3/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "3.0.0"}}}, "deviceFamilies": ["ipad"]}}`,
}
var newTeamResp teamResponse
s.DoJSON("POST", "/api/latest/fleet/teams", &createTeamRequest{TeamPayload: fleet.TeamPayload{Name: ptr.String("Team 1" + t.Name())}}, http.StatusOK, &newTeamResp)
team := newTeamResp.Team
noopAuthenticator := func(bool) (string, error) { return "", nil } // authentication is tested elsewhere
// Set up VPP token
orgName := "Fleet Device Management Inc."
token := "mycooltoken"
@@ -17494,7 +17497,7 @@ func (s *integrationMDMTestSuite) TestRefreshVPPAppVersions() {
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", resp.Tokens[0].ID), patchVPPTokensTeamsRequest{TeamIDs: []uint{team.ID}}, http.StatusOK, &resPatchVPP)
// No VPP apps added yet, so this is a no-op
err := vpp.RefreshVersions(ctx, s.ds)
err := vpp.RefreshVersions(ctx, s.ds, noopAuthenticator)
require.NoError(t, err)
var appResp getAppStoreAppsResponse
@@ -17543,11 +17546,10 @@ func (s *integrationMDMTestSuite) TestRefreshVPPAppVersions() {
require.Equal(t, "3.0.0", listSWTitlesResp.SoftwareTitles[0].AppStoreApp.Version)
// "update" the versions
s.appleITunesSrvData["1"] = `{"bundleId": "a-1", "artworkUrl512": "https://example.com/images/1", "version": "9.9.9", "trackName": "App 1", "TrackID": 1}`
s.appleITunesSrvData["2"] = `{"bundleId": "b-2", "artworkUrl512": "https://example.com/images/2", "version": "10.10.10", "trackName": "App 2", "TrackID": 2,
"supportedDevices": ["MacDesktop-MacDesktop", "iPhone5s-iPhone5s", "iPadAir-iPadAir"] }`
s.appleVPPProxySrvData["1"] = `{"id": "1", "attributes": {"name": "App 1", "platformAttributes": {"osx": {"bundleId": "a-1", "artwork": {"url": "https://example.com/images/1/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "9.9.9"}}}, "deviceFamilies": ["mac"]}}`
s.appleVPPProxySrvData["2"] = `{"id": "2", "attributes": {"name": "App 2", "platformAttributes": {"osx": {"bundleId": "b-2", "artwork": {"url": "https://example.com/images/2/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "10.10.10"}}, "ios": {"bundleId": "b-2", "artwork": {"url": "https://example.com/images/2/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "10.10.10"}}}, "deviceFamilies": ["mac", "iphone", "ipad"]}}`
err = vpp.RefreshVersions(ctx, s.ds)
err = vpp.RefreshVersions(ctx, s.ds, noopAuthenticator)
require.NoError(t, err)
// 1 and 2 should be updated
@@ -17568,25 +17570,27 @@ func (s *integrationMDMTestSuite) TestRefreshVPPAppVersions() {
require.Equal(t, "3.0.0", listSWTitlesResp.SoftwareTitles[0].AppStoreApp.Version)
// Refresh again. There are no version changes this time, so this is a no-op.
err = vpp.RefreshVersions(ctx, s.ds)
err = vpp.RefreshVersions(ctx, s.ds, noopAuthenticator)
require.NoError(t, err)
}
func (s *integrationMDMTestSuite) TestRefreshVPPAppVersionsForAllPlatforms() {
t := s.T()
// Reset the iTunes data to what it was before this test
s.registerResetITunesData(t)
// Reset the VPP proxy data to what it was before this test
s.registerResetVPPProxyData(t)
// Set up app with adamID 1 with iOS, iPadOS, macOS (e.g. WhatsApp).
// Set up app with adamID 2 with iOS and iPadOS.
// Set up app with adamID 3 with iOS.
s.appleITunesSrvData = map[string]string{
"1": `{"bundleId": "a-1", "artworkUrl512": "https://example.com/images/1", "version": "1.0.0", "trackName": "App 1", "TrackID": 1, "supportedDevices": ["MacDesktop-MacDesktop", "iPhone5s-iPhone5s", "iPadAir-iPadAir"]}`,
"2": `{"bundleId": "d-2", "artworkUrl512": "https://example.com/images/2", "version": "2.0.0", "trackName": "App 2", "TrackID": 2, "supportedDevices": ["iPhone5s-iPhone5s", "iPadAir-iPadAir"] }`,
"3": `{"bundleId": "b-3", "artworkUrl512": "https://example.com/images/3", "version": "3.0.0", "trackName": "App 3", "TrackID": 3, "supportedDevices": ["iPhone5s-iPhone5s"] }`,
s.appleVPPProxySrvData = map[string]string{
"1": `{"id": "1", "attributes": {"name": "App 1", "platformAttributes": {"osx": {"bundleId": "a-1", "artwork": {"url": "https://example.com/images/1/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "1.2.3"}}, "ios": {"bundleId": "a-1", "artwork": {"url": "https://example.com/images/1/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "1.3.0"}}}, "deviceFamilies": ["mac", "iphone", "ipad"]}}`,
"2": `{"id": "2", "attributes": {"name": "App 2", "platformAttributes": {"ios": {"bundleId": "d-2", "artwork": {"url": "https://example.com/images/2/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "2.0.0"}}}, "deviceFamilies": ["iphone", "ipad"]}}`,
"3": `{"id": "3", "attributes": {"name": "App 3", "platformAttributes": {"ios": {"bundleId": "b-3", "artwork": {"url": "https://example.com/images/3/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "3.0.0"}}}, "deviceFamilies": ["iphone"]}}`,
}
noopAuthenticator := func(bool) (string, error) { return "", nil } // authentication is tested elsewhere
var newTeamResp teamResponse
s.DoJSON("POST", "/api/latest/fleet/teams", &createTeamRequest{TeamPayload: fleet.TeamPayload{Name: ptr.String("Team 1" + t.Name())}}, http.StatusOK, &newTeamResp)
team := newTeamResp.Team
@@ -17674,9 +17678,9 @@ func (s *integrationMDMTestSuite) TestRefreshVPPAppVersionsForAllPlatforms() {
// Check versions before refresh
for titleID, expectedVersion := range map[uint]string{
app1MacOS.TitleID: "1.0.0",
app1IOS.TitleID: "1.0.0",
app1IPadOS.TitleID: "1.0.0",
app1MacOS.TitleID: "1.2.3",
app1IOS.TitleID: "1.3.0",
app1IPadOS.TitleID: "1.3.0",
app2IOS.TitleID: "2.0.0",
app2IPadOS.TitleID: "2.0.0",
app3IOS.TitleID: "3.0.0",
@@ -17688,17 +17692,17 @@ func (s *integrationMDMTestSuite) TestRefreshVPPAppVersionsForAllPlatforms() {
}
// "Update" the versions for Adam ID "1" and "2".
s.appleITunesSrvData["1"] = `{"bundleId": "a-1", "artworkUrl512": "https://example.com/images/1", "version": "9.9.9", "trackName": "App 1", "TrackID": 1, "supportedDevices": ["MacDesktop-MacDesktop", "iPhone5s-iPhone5s", "iPadAir-iPadAir"]}`
s.appleITunesSrvData["2"] = `{"bundleId": "b-2", "artworkUrl512": "https://example.com/images/2", "version": "10.10.10", "trackName": "App 2", "TrackID": 2, "supportedDevices": ["iPhone5s-iPhone5s", "iPadAir-iPadAir"]}`
s.appleVPPProxySrvData["1"] = `{"id": "1", "attributes": {"name": "App 1", "platformAttributes": {"osx": {"bundleId": "a-1", "artwork": {"url": "https://example.com/images/1/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "9.9.9"}}, "ios": {"bundleId": "a-1", "artwork": {"url": "https://example.com/images/1/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "9.9.8"}}}, "deviceFamilies": ["mac", "iphone", "ipad"]}}`
s.appleVPPProxySrvData["2"] = `{"id": "2", "attributes": {"name": "App 2", "platformAttributes": {"ios": {"bundleId": "b-2", "artwork": {"url": "https://example.com/images/2/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "10.10.10"}}}, "deviceFamilies": ["iphone", "ipad"]}}`
err := vpp.RefreshVersions(t.Context(), s.ds)
err := vpp.RefreshVersions(t.Context(), s.ds, noopAuthenticator)
require.NoError(t, err)
// Check versions after refresh
for titleID, expectedVersion := range map[uint]string{
app1MacOS.TitleID: "9.9.9",
app1IOS.TitleID: "9.9.9",
app1IPadOS.TitleID: "9.9.9",
app1IOS.TitleID: "9.9.8",
app1IPadOS.TitleID: "9.9.8",
app2IOS.TitleID: "10.10.10",
app2IPadOS.TitleID: "10.10.10",
app3IOS.TitleID: "3.0.0",
@@ -17710,16 +17714,16 @@ func (s *integrationMDMTestSuite) TestRefreshVPPAppVersionsForAllPlatforms() {
}
// "Update" the version for Adam ID "3".
s.appleITunesSrvData["3"] = `{"bundleId": "b-3", "artworkUrl512": "https://example.com/images/3", "version": "11.11.11", "trackName": "App 3", "TrackID": 3, "supportedDevices": ["iPhone5s-iPhone5s"] }`
s.appleVPPProxySrvData["3"] = `{"id": "3", "attributes": {"name": "App 3", "platformAttributes": {"ios": {"bundleId": "b-3", "artwork": {"url": "https://example.com/images/3/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "11.11.11"}}}, "deviceFamilies": ["iphone"]}}`
err = vpp.RefreshVersions(t.Context(), s.ds)
err = vpp.RefreshVersions(t.Context(), s.ds, noopAuthenticator)
require.NoError(t, err)
// Check versions after refresh
for titleID, expectedVersion := range map[uint]string{
app1MacOS.TitleID: "9.9.9",
app1IOS.TitleID: "9.9.9",
app1IPadOS.TitleID: "9.9.9",
app1IOS.TitleID: "9.9.8",
app1IPadOS.TitleID: "9.9.8",
app2IOS.TitleID: "10.10.10",
app2IPadOS.TitleID: "10.10.10",
app3IOS.TitleID: "11.11.11",
@@ -17731,7 +17735,7 @@ func (s *integrationMDMTestSuite) TestRefreshVPPAppVersionsForAllPlatforms() {
}
// Refresh again. There are no version changes this time, so this is a no-op.
err = vpp.RefreshVersions(t.Context(), s.ds)
err = vpp.RefreshVersions(t.Context(), s.ds, noopAuthenticator)
require.NoError(t, err)
}
@@ -214,7 +214,7 @@ func (s *integrationMDMTestSuite) TestSoftwareTitleDisplayNames() {
},
Name: "App 1",
BundleIdentifier: "a-1",
IconURL: "https://example.com/images/1",
IconURL: "https://example.com/images/1/512x512.png",
LatestVersion: "1.0.0",
}
+12 -12
View File
@@ -82,7 +82,7 @@ func (s *integrationMDMTestSuite) TestVPPAppInstallVerification() {
},
Name: "App 1",
BundleIdentifier: "a-1",
IconURL: "https://example.com/images/1",
IconURL: "https://example.com/images/1/512x512.png",
LatestVersion: "1.0.0",
}
@@ -95,7 +95,7 @@ func (s *integrationMDMTestSuite) TestVPPAppInstallVerification() {
},
Name: "App 2",
BundleIdentifier: "b-2",
IconURL: "https://example.com/images/2",
IconURL: "https://example.com/images/2/512x512.png",
LatestVersion: "2.0.0",
}
@@ -108,7 +108,7 @@ func (s *integrationMDMTestSuite) TestVPPAppInstallVerification() {
},
Name: "App 3",
BundleIdentifier: "c-3",
IconURL: "https://example.com/images/3",
IconURL: "https://example.com/images/3/512x512.png",
LatestVersion: "2.0.0",
}
var addAppResp addAppStoreAppResponse
@@ -167,8 +167,8 @@ func (s *integrationMDMTestSuite) TestVPPAppInstallVerification() {
},
Name: "App 2",
BundleIdentifier: "b-2",
IconURL: "https://example.com/images/2",
LatestVersion: "2.0.0",
IconURL: "https://example.com/images/2/512x512.png",
LatestVersion: "2.0.1", // macOS has different version than iOS
}
expectedApps := []*fleet.VPPApp{macOSApp, errApp, iOSApp, iPadOSApp}
expectedAppsByBundleID := map[string]*fleet.VPPApp{
@@ -338,7 +338,7 @@ func (s *integrationMDMTestSuite) TestVPPAppInstallVerification() {
require.Equal(t, expected.Name, got.Name)
require.NotNil(t, got.AppStoreApp)
require.Equal(t, expected.AdamID, got.AppStoreApp.AppStoreID)
require.Equal(t, ptr.String(expected.IconURL), got.IconUrl)
require.Equal(t, expected.IconURL, *got.IconUrl)
require.Empty(t, got.AppStoreApp.Name) // Name is only present for installer packages
require.Equal(t, expected.LatestVersion, got.AppStoreApp.Version)
require.NotNil(t, got.Status)
@@ -1042,7 +1042,7 @@ func (s *integrationMDMTestSuite) TestVPPAppActivitiesOnCancelInstall() {
},
Name: "App 1",
BundleIdentifier: "a-1",
IconURL: "https://example.com/images/1",
IconURL: "https://example.com/images/1/512x512.png",
LatestVersion: "1.0.0",
}
@@ -1055,7 +1055,7 @@ func (s *integrationMDMTestSuite) TestVPPAppActivitiesOnCancelInstall() {
},
Name: "App 2",
BundleIdentifier: "b-2",
IconURL: "https://example.com/images/2",
IconURL: "https://example.com/images/2/512x512.png",
LatestVersion: "2.0.0",
}
@@ -1242,11 +1242,11 @@ func (s *integrationMDMTestSuite) TestSoftwareTitleVPPAppSoftwarePackageConflict
t := s.T()
s.setSkipWorkerJobs(t)
s.registerResetITunesData(t)
s.registerResetVPPProxyData(t)
s.appleITunesSrvData = map[string]string{
"1": `{"bundleId": "com.example.dummy", "artworkUrl512": "https://example.com/images/1", "version": "1.0.0", "trackName": "DummyApp", "TrackID": 1}`,
"2": `{"bundleId": "com.example.noversion", "artworkUrl512": "https://example.com/images/2", "version": "2.0.0", "trackName": "NoVersion", "TrackID": 2}`,
s.appleVPPProxySrvData = map[string]string{
"1": `{"id": "1", "attributes": {"name": "DummyApp", "platformAttributes": {"osx": {"bundleId": "com.example.dummy", "artwork": {"url": "https://example.com/images/1/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "1.0.0"}}}, "deviceFamilies": ["mac"]}}`,
"2": `{"id": "2", "attributes": {"name": "NoVersion", "platformAttributes": {"osx": {"bundleId": "com.example.noversion", "artwork": {"url": "https://example.com/images/2/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "2.0.0"}}}, "deviceFamilies": ["mac"]}}`,
}
var newTeamResp teamResponse