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:
+36
-36
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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{
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user