Android WebApps: endpoint to create one, prevent android app configuration on webApps (#40329)

This commit is contained in:
Martin Angers
2026-03-02 15:14:19 -05:00
committed by GitHub
parent a52ae1de52
commit 22d5498f2e
24 changed files with 684 additions and 51 deletions
@@ -0,0 +1 @@
- Added support to create an Android webapp and add it to Fleet to be installed during setup experience or via self-service.
+102 -1
View File
@@ -1,13 +1,17 @@
package service
import (
"bytes"
"context"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"image/png"
"io"
"maps"
"net/http"
"net/url"
"regexp"
"slices"
"sort"
@@ -18,10 +22,12 @@ import (
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/android/service/androidmgmt"
"github.com/fleetdm/fleet/v4/server/mdm/apple/apple_apps"
"github.com/fleetdm/fleet/v4/server/mdm/apple/vpp"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/worker"
"google.golang.org/api/androidmanagement/v1"
)
// Used for overriding the env var value in testing
@@ -212,6 +218,10 @@ func (svc *Service) BatchAssociateVPPApps(ctx context.Context, teamName string,
}
switch payload.Platform {
case fleet.AndroidPlatform:
if strings.HasPrefix(payload.AppStoreID, fleet.AndroidWebAppPrefix) && payload.Configuration != nil {
return nil, fleet.NewInvalidArgumentError("configuration", "Couldn't edit. Android web apps don't support configurations.")
}
appStoreApp.SelfService = true
appStoreApp.Configuration = payload.Configuration
incomingAndroidApps = append(incomingAndroidApps, appStoreApp)
@@ -278,7 +288,7 @@ func (svc *Service) BatchAssociateVPPApps(ctx context.Context, teamName string,
enterprise, err := svc.ds.GetEnterprise(ctx)
if err != nil && !fleet.IsNotFound(err) {
return nil, &fleet.BadRequestError{Message: "Android MDM is not enabled", InternalErr: err}
return nil, ctxerr.Wrap(ctx, err, "get android enterprise")
}
androidHostPoliciesToUpdate := map[string]string{}
@@ -605,6 +615,11 @@ func (svc *Service) AddAppStoreApp(ctx context.Context, teamID *uint, appID flee
return 0, fleet.NewInvalidArgumentError("app_store_id", "The Fleet agent cannot be added manually. "+
"It is automatically managed by Fleet when Android MDM is enabled.")
}
if strings.HasPrefix(appID.AdamID, fleet.AndroidWebAppPrefix) && appID.Configuration != nil {
return 0, fleet.NewInvalidArgumentError("configuration", "Couldn't add. Android web apps don't support configurations.")
}
appID.SelfService = true
appID.AddAutoInstallPolicy = false
@@ -999,6 +1014,10 @@ func (svc *Service) UpdateAppStoreApp(ctx context.Context, titleID uint, teamID
// note that if appID.Configuration is nil, InsertVPPAppWithTeam will ignore it (it will not
// update or remove it), so here we ignore it too if it is nil.
if payload.Configuration != nil && meta.Platform == fleet.AndroidPlatform {
if strings.HasPrefix(meta.AdamID, fleet.AndroidWebAppPrefix) {
return nil, nil, fleet.NewInvalidArgumentError("configuration", "Couldn't edit. Android web apps don't support configurations.")
}
// check if configuration has changed
androidConfigChanged, err = svc.ds.HasAndroidAppConfigurationChanged(ctx, meta.AdamID, ptr.ValOrZero(teamID), payload.Configuration)
if err != nil {
@@ -1217,3 +1236,85 @@ func (svc *Service) DeleteVPPToken(ctx context.Context, tokenID uint) error {
return svc.ds.DeleteVPPToken(ctx, tokenID)
}
func (svc *Service) CreateAndroidWebApp(ctx context.Context, title, startURL string, icon io.Reader) (string, error) {
// Authorization for this endpoint is a bit different - basically we want the same
// write permissions as for App Store apps (fleet.VPPApp struct), but there is no
// team id available when this endpoint is called, so we allow any team user to
// call it as long as they have the acceptable role. To achieve this, we grab the
// first team id from the user's list of teams, if they have a non-global role.
var teamID *uint
if user := authz.UserFromContext(ctx); user != nil {
if len(user.Teams) > 0 {
teamID = &user.Teams[0].ID
}
}
if err := svc.authz.Authorize(ctx, &fleet.VPPApp{TeamID: teamID}, fleet.ActionWrite); err != nil {
return "", err
}
// title and startURL are required but are already validated during the DecodeRequest implementation.
if parsedURL, err := url.Parse(startURL); err != nil || !parsedURL.IsAbs() {
return "", &fleet.BadRequestError{Message: "Couldn't create. The start URL must be a valid absolute URL.", InternalErr: err}
}
// icon, if provided, must be a .png file and must be square and at least 512x512 pixels.
var iconData []byte
if icon != nil {
const invalidIconErrMsg = `Couldn't create. The icon must be a PNG file and square, with dimensions of at least 512 x 512px.`
b, err := io.ReadAll(icon)
if err != nil {
return "", &fleet.BadRequestError{Message: invalidIconErrMsg, InternalErr: err}
}
iconData = b
// decoding errors if it is not a valid png
cfg, err := png.DecodeConfig(bytes.NewReader(iconData))
if err != nil {
return "", &fleet.BadRequestError{Message: invalidIconErrMsg, InternalErr: err}
}
// check that it is square
if cfg.Width != cfg.Height {
return "", &fleet.BadRequestError{Message: invalidIconErrMsg}
}
// check minimal size requirement (only needs to test one, as at this point it is square)
if cfg.Width < 512 {
return "", &fleet.BadRequestError{Message: invalidIconErrMsg}
}
}
enterprise, err := svc.ds.GetEnterprise(ctx)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "get android enterprise")
}
webApp := &androidmanagement.WebApp{
DisplayMode: "STANDALONE", // always standalone for now per spec
Title: title,
StartUrl: startURL,
}
if len(iconData) > 0 {
// must be the "actual bytes of the image in a base64url encoded string"
b64Icon := base64.URLEncoding.EncodeToString(iconData)
webApp.Icons = append(webApp.Icons, &androidmanagement.WebAppIcon{ImageData: b64Icon})
}
createdApp, err := svc.androidModule.CreateAndroidWebApp(ctx, enterprise.Name(), webApp)
if err != nil {
if androidmgmt.IsBadRequestError(err) {
return "", &fleet.BadRequestError{Message: "Couldn't create. Please check the provided data and try again.", InternalErr: err}
}
return "", ctxerr.Wrap(ctx, err, "creating android web app")
}
packageName := strings.TrimPrefix(createdApp.Name, fmt.Sprintf("%s/webApps/", enterprise.Name()))
if packageName == createdApp.Name || !strings.HasPrefix(packageName, fleet.AndroidWebAppPrefix) {
// logging this as an error, because the frontend uses the package name to hide some actions
// not available to WebApps, we must know if somehow android changes how those get named.
svc.logger.ErrorContext(ctx, "created Android webApp does not have expected package name format", "package_name", createdApp.Name)
}
return packageName, nil
}
+2
View File
@@ -14,6 +14,8 @@ import (
"google.golang.org/api/androidmanagement/v1"
)
const AndroidWebAppPrefix = "com.google.enterprise.webapp"
// MDMAndroidConfigProfile represents an Android MDM profile in Fleet. This does not map
// directly to a specific policy in the Android API, rather the policy applied is the
// result of combining all applicable profiles.
+7
View File
@@ -865,6 +865,8 @@ type Service interface {
GetVPPTokens(ctx context.Context) ([]*VPPTokenDB, error)
DeleteVPPToken(ctx context.Context, tokenID uint) error
CreateAndroidWebApp(ctx context.Context, title, startURL string, icon io.Reader) (string, error)
BatchAssociateVPPApps(ctx context.Context, teamName string, payloads []VPPBatchPayload, dryRun bool) ([]VPPAppResponse, error)
// GetHostDEPAssignment retrieves the host DEP assignment for the specified host.
@@ -1038,6 +1040,11 @@ type Service interface {
// error can be raised to the user.
VerifyMDMWindowsConfigured(ctx context.Context) error
// VerifyMDMAndroidConfigured verifies that the server is configured for
// Android MDM. If an error is returned, authorization is skipped so the
// error can be raised to the user.
VerifyMDMAndroidConfigured(ctx context.Context) error
// VerifyAnyMDMConfigured verifies that the server is configured for any MDM
// (Apple, Windows, or Android). If an error is returned, authorization is
// skipped so the error can be raised to the user.
+12
View File
@@ -41,6 +41,8 @@ type EnterprisesPoliciesModifyPolicyApplicationsFunc func(ctx context.Context, p
type EnterprisesPoliciesRemovePolicyApplicationsFunc func(ctx context.Context, policyName string, packageNames []string) (*androidmanagement.Policy, error)
type EnterprisesWebAppsCreateFunc func(ctx context.Context, enterpriseName string, webApp *androidmanagement.WebApp) (*androidmanagement.WebApp, error)
type Client struct {
SignupURLsCreateFunc SignupURLsCreateFunc
SignupURLsCreateFuncInvoked bool
@@ -84,6 +86,9 @@ type Client struct {
EnterprisesPoliciesRemovePolicyApplicationsFunc EnterprisesPoliciesRemovePolicyApplicationsFunc
EnterprisesPoliciesRemovePolicyApplicationsFuncInvoked bool
EnterprisesWebAppsCreateFunc EnterprisesWebAppsCreateFunc
EnterprisesWebAppsCreateFuncInvoked bool
mu sync.Mutex
}
@@ -184,3 +189,10 @@ func (p *Client) EnterprisesPoliciesRemovePolicyApplications(ctx context.Context
p.mu.Unlock()
return p.EnterprisesPoliciesRemovePolicyApplicationsFunc(ctx, policyName, packageNames)
}
func (p *Client) EnterprisesWebAppsCreate(ctx context.Context, enterpriseName string, webApp *androidmanagement.WebApp) (*androidmanagement.WebApp, error) {
p.mu.Lock()
p.EnterprisesWebAppsCreateFuncInvoked = true
p.mu.Unlock()
return p.EnterprisesWebAppsCreateFunc(ctx, enterpriseName, webApp)
}
+4 -1
View File
@@ -41,10 +41,13 @@ type Service interface {
PatchDevice(ctx context.Context, policyID, deviceName string, device *androidmanagement.Device) (skip bool, apiErr error)
PatchPolicy(ctx context.Context, policyID, policyName string, policy *androidmanagement.Policy, metadata map[string]string) (skip bool, err error)
// verifyExistingEnterpriseIfAny checks if there's an existing enterprise in the database
// VerifyExistingEnterpriseIfAny checks if there's an existing enterprise in the database
// and if so, verifies it still exists in Google API. If it doesn't exist, performs cleanup.
// Returns fleet.IsNotFound error if enterprise was deleted, nil if no enterprise exists or verification passed.
VerifyExistingEnterpriseIfAny(ctx context.Context) error
// CreateAndroidWebApp creates a new web app for the given enterprise.
CreateAndroidWebApp(ctx context.Context, enterpriseName string, app *androidmanagement.WebApp) (*androidmanagement.WebApp, error)
}
// /////////////////////////////////////////////
@@ -2,6 +2,8 @@ package androidmgmt
import (
"context"
"errors"
"net/http"
"github.com/fleetdm/fleet/v4/server/mdm/android"
"google.golang.org/api/androidmanagement/v1"
@@ -63,9 +65,17 @@ type Client interface {
EnterprisesApplications(ctx context.Context, enterpriseName, packageName string) (*androidmanagement.Application, error)
// EnterprisesPoliciesModifyPolicyApplications adds or updates the given apps in the policy.
// See: https://developers.google.com/android/management/reference/rest/v1/enterprises.policies/modifyPolicyApplications
EnterprisesPoliciesModifyPolicyApplications(ctx context.Context, policyName string, appPolicies []*androidmanagement.ApplicationPolicy) (*androidmanagement.Policy, error)
// EnterprisesPoliciesRemovePolicyApplications removes the given apps from the policy.
// See: https://developers.google.com/android/management/reference/rest/v1/enterprises.policies/removePolicyApplications
EnterprisesPoliciesRemovePolicyApplications(ctx context.Context, policyName string, packageNames []string) (*androidmanagement.Policy, error)
// EnterprisesWebAppsCreate creates a web app in the enterprise.
// See: https://developers.google.com/android/management/reference/rest/v1/enterprises.webApps/create
EnterprisesWebAppsCreate(ctx context.Context, enterpriseName string, webApp *androidmanagement.WebApp) (*androidmanagement.WebApp, error)
}
type EnterprisesCreateRequest struct {
@@ -96,3 +106,13 @@ type EnterprisesCreateResponse struct {
func IsNotModifiedError(err error) bool {
return googleapi.IsNotModified(err)
}
// IsBadRequestError reports whether the AMAPI error indicates that the
// request was invalid due to a client error.
func IsBadRequestError(err error) bool {
var ae *googleapi.Error
if errors.As(err, &ae) {
return ae.Code == http.StatusBadRequest
}
return false
}
@@ -392,3 +392,11 @@ func (g *GoogleClient) EnterprisesPoliciesRemovePolicyApplications(ctx context.C
}
return ret.Policy, nil
}
func (g *GoogleClient) EnterprisesWebAppsCreate(ctx context.Context, enterpriseName string, webApp *androidmanagement.WebApp) (*androidmanagement.WebApp, error) {
ret, err := g.mgmt.Enterprises.WebApps.Create(enterpriseName, webApp).Context(ctx).Do()
if err != nil {
return nil, ctxerr.Wrapf(ctx, err, "create webapp")
}
return ret, nil
}
@@ -357,3 +357,13 @@ func (p *ProxyClient) EnterprisesPoliciesRemovePolicyApplications(ctx context.Co
}
return ret.Policy, nil
}
func (p *ProxyClient) EnterprisesWebAppsCreate(ctx context.Context, enterpriseName string, webApp *androidmanagement.WebApp) (*androidmanagement.WebApp, error) {
call := p.mgmt.Enterprises.WebApps.Create(enterpriseName, webApp).Context(ctx)
call.Header().Set("Authorization", "Bearer "+p.fleetServerSecret)
ret, err := call.Do()
if err != nil {
return nil, ctxerr.Wrapf(ctx, err, "create webapp")
}
return ret, nil
}
+8
View File
@@ -1388,3 +1388,11 @@ func (svc *Service) SetAppsForAndroidPolicy(ctx context.Context, enterpriseName
}
return errors.Join(errs...)
}
func (svc *Service) CreateAndroidWebApp(ctx context.Context, enterpriseName string, app *androidmanagement.WebApp) (*androidmanagement.WebApp, error) {
app, err := svc.androidAPIClient.EnterprisesWebAppsCreate(ctx, enterpriseName, app)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "creating Android web app")
}
return app, nil
}
+24
View File
@@ -546,6 +546,8 @@ type GetVPPTokensFunc func(ctx context.Context) ([]*fleet.VPPTokenDB, error)
type DeleteVPPTokenFunc func(ctx context.Context, tokenID uint) error
type CreateAndroidWebAppFunc func(ctx context.Context, title string, startURL string, icon io.Reader) (string, error)
type BatchAssociateVPPAppsFunc func(ctx context.Context, teamName string, payloads []fleet.VPPBatchPayload, dryRun bool) ([]fleet.VPPAppResponse, error)
type GetHostDEPAssignmentFunc func(ctx context.Context, host *fleet.Host) (*fleet.HostDEPAssignment, error)
@@ -640,6 +642,8 @@ type VerifyMDMAppleConfiguredFunc func(ctx context.Context) error
type VerifyMDMWindowsConfiguredFunc func(ctx context.Context) error
type VerifyMDMAndroidConfiguredFunc func(ctx context.Context) error
type VerifyAnyMDMConfiguredFunc func(ctx context.Context) error
type MDMAppleUploadBootstrapPackageFunc func(ctx context.Context, name string, pkg io.Reader, teamID uint, dryRun bool) error
@@ -1674,6 +1678,9 @@ type Service struct {
DeleteVPPTokenFunc DeleteVPPTokenFunc
DeleteVPPTokenFuncInvoked bool
CreateAndroidWebAppFunc CreateAndroidWebAppFunc
CreateAndroidWebAppFuncInvoked bool
BatchAssociateVPPAppsFunc BatchAssociateVPPAppsFunc
BatchAssociateVPPAppsFuncInvoked bool
@@ -1815,6 +1822,9 @@ type Service struct {
VerifyMDMWindowsConfiguredFunc VerifyMDMWindowsConfiguredFunc
VerifyMDMWindowsConfiguredFuncInvoked bool
VerifyMDMAndroidConfiguredFunc VerifyMDMAndroidConfiguredFunc
VerifyMDMAndroidConfiguredFuncInvoked bool
VerifyAnyMDMConfiguredFunc VerifyAnyMDMConfiguredFunc
VerifyAnyMDMConfiguredFuncInvoked bool
@@ -4025,6 +4035,13 @@ func (s *Service) DeleteVPPToken(ctx context.Context, tokenID uint) error {
return s.DeleteVPPTokenFunc(ctx, tokenID)
}
func (s *Service) CreateAndroidWebApp(ctx context.Context, title string, startURL string, icon io.Reader) (string, error) {
s.mu.Lock()
s.CreateAndroidWebAppFuncInvoked = true
s.mu.Unlock()
return s.CreateAndroidWebAppFunc(ctx, title, startURL, icon)
}
func (s *Service) BatchAssociateVPPApps(ctx context.Context, teamName string, payloads []fleet.VPPBatchPayload, dryRun bool) ([]fleet.VPPAppResponse, error) {
s.mu.Lock()
s.BatchAssociateVPPAppsFuncInvoked = true
@@ -4354,6 +4371,13 @@ func (s *Service) VerifyMDMWindowsConfigured(ctx context.Context) error {
return s.VerifyMDMWindowsConfiguredFunc(ctx)
}
func (s *Service) VerifyMDMAndroidConfigured(ctx context.Context) error {
s.mu.Lock()
s.VerifyMDMAndroidConfiguredFuncInvoked = true
s.mu.Unlock()
return s.VerifyMDMAndroidConfiguredFunc(ctx)
}
func (s *Service) VerifyAnyMDMConfigured(ctx context.Context) error {
s.mu.Lock()
s.VerifyAnyMDMConfiguredFuncInvoked = true
+3
View File
@@ -867,6 +867,9 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
ue.POST("/api/_version_/fleet/spec/certificate_authorities", batchApplyCertificateAuthoritiesEndpoint, batchApplyCertificateAuthoritiesRequest{})
ue.GET("/api/_version_/fleet/spec/certificate_authorities", getCertificateAuthoritiesSpecEndpoint, getCertificateAuthoritiesSpecRequest{})
mdmAndroidMW := ue.WithCustomMiddleware(mdmConfiguredMiddleware.VerifyAndroidMDM())
mdmAndroidMW.POST("/api/_version_/fleet/software/web_apps", createAndroidWebAppEndpoint, createAndroidWebAppRequest{})
ipBanner := redis.NewIPBanner(redisPool, "ipbanner::",
deviceIPAllowedConsecutiveFailingRequestsCount,
deviceIPAllowedConsecutiveFailingRequestsTimeWindow,
@@ -6,6 +6,8 @@ import (
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"slices"
"strings"
"testing"
@@ -1105,3 +1107,225 @@ func (s *integrationMDMTestSuite) TestAndroidAppsUninstallOnDelete() {
require.NotNil(t, getHostSw.Software[0].AppStoreApp)
require.Equal(t, androidApps[4].AdamID, getHostSw.Software[0].AppStoreApp.AppStoreID)
}
func (s *integrationMDMTestSuite) TestAndroidWebApps() {
ctx := context.Background()
t := s.T()
s.setSkipWorkerJobs(t)
appConf, err := s.ds.AppConfig(ctx)
require.NoError(t, err)
appConf.MDM.AndroidEnabledAndConfigured = false
err = s.ds.SaveAppConfig(ctx, appConf)
require.NoError(t, err)
// attempt to create a webapp before android MDM is enabled
body, headers := generateMultipartRequest(t, "", "", nil, s.token, map[string][]string{
"title": {"Web App"},
"url": {"https://example.com"},
})
res := s.DoRawWithHeaders("POST", "/api/latest/fleet/software/web_apps", body.Bytes(), http.StatusBadRequest, headers)
require.Contains(t, extractServerErrorText(res.Body), "Android MDM isn't turned on.")
enterpriseID := s.enableAndroidMDM(t)
s.androidAPIClient.EnterprisesWebAppsCreateFunc = func(ctx context.Context, enterpriseName string, app *androidmanagement.WebApp) (*androidmanagement.WebApp, error) {
id := uuid.NewString()
return &androidmanagement.WebApp{Name: fmt.Sprintf("enterprises/%s/webApps/%s", enterpriseID, id)}, nil
}
cases := []struct {
desc string
title string
url string
iconFile string // filename in testdata/icons/
wantStatus int
wantErrMsg string
}{
{
desc: "missing title",
title: "",
url: "http://example.com",
wantStatus: http.StatusBadRequest,
wantErrMsg: "title multipart field is required",
},
{
desc: "missing url",
title: "WebApp",
url: "",
wantStatus: http.StatusBadRequest,
wantErrMsg: "url multipart field is required",
},
{
desc: "invalid url",
title: "WebApp",
url: "non-absolute-url",
wantStatus: http.StatusBadRequest,
wantErrMsg: "The start URL must be a valid absolute URL.",
},
{
desc: "valid without icon",
title: "WebApp",
url: "http://example.com",
wantStatus: http.StatusOK,
wantErrMsg: "",
},
{
desc: "invalid icon not a png",
title: "WebApp",
url: "http://example.com",
iconFile: "not-a-png.txt",
wantStatus: http.StatusBadRequest,
wantErrMsg: "The icon must be a PNG file and square, with dimensions of at least 512 x 512px.",
},
{
desc: "invalid icon not square",
title: "WebApp",
url: "http://example.com",
iconFile: "non-square.png",
wantStatus: http.StatusBadRequest,
wantErrMsg: "The icon must be a PNG file and square, with dimensions of at least 512 x 512px.",
},
{
desc: "invalid icon too small",
title: "WebApp",
url: "http://example.com",
iconFile: "200px-square.png",
wantStatus: http.StatusBadRequest,
wantErrMsg: "The icon must be a PNG file and square, with dimensions of at least 512 x 512px.",
},
{
desc: "valid with icon",
title: "WebApp",
url: "http://example.com",
iconFile: "512px-square.png",
wantStatus: http.StatusOK,
wantErrMsg: "",
},
}
for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
var filename string
var iconData []byte
if c.iconFile != "" {
filename = c.iconFile
b, err := os.ReadFile(filepath.Join("testdata", "icons", c.iconFile))
require.NoError(t, err)
iconData = b
}
body, headers := generateMultipartRequest(t, "icon", filename, iconData, s.token, map[string][]string{
"title": {c.title},
"url": {c.url},
})
res := s.DoRawWithHeaders("POST", "/api/latest/fleet/software/web_apps", body.Bytes(), c.wantStatus, headers)
if c.wantErrMsg != "" {
require.Contains(t, extractServerErrorText(res.Body), c.wantErrMsg)
} else {
var resp createAndroidWebAppResponse
err := json.NewDecoder(res.Body).Decode(&resp)
require.NoError(t, err)
require.NotEmpty(t, resp.AppStoreID)
}
})
}
}
func (s *integrationMDMTestSuite) TestAndroidWebAppsCannotSetConfiguration() {
ctx := context.Background()
t := s.T()
s.setSkipWorkerJobs(t)
appConf, err := s.ds.AppConfig(ctx)
require.NoError(t, err)
appConf.MDM.AndroidEnabledAndConfigured = false
err = s.ds.SaveAppConfig(ctx, appConf)
require.NoError(t, err)
enterpriseID := s.enableAndroidMDM(t)
var count int
s.androidAPIClient.EnterprisesWebAppsCreateFunc = func(ctx context.Context, enterpriseName string, app *androidmanagement.WebApp) (*androidmanagement.WebApp, error) {
count++
id := "abc" + fmt.Sprint(count)
return &androidmanagement.WebApp{Name: fmt.Sprintf("enterprises/%s/webApps/com.google.enterprise.webapp.%s", enterpriseID, id)}, nil
}
s.androidAPIClient.EnterprisesApplicationsFunc = func(ctx context.Context, enterpriseName string, packageName string) (*androidmanagement.Application, error) {
ix := strings.LastIndex(packageName, ".") // title is the final segment
return &androidmanagement.Application{IconUrl: "https://example.com/1.jpg", Title: packageName[ix+1:]}, nil
}
// create a webapp
body, headers := generateMultipartRequest(t, "", "", nil, s.token, map[string][]string{
"title": {"Web App"},
"url": {"https://example.com"},
})
var resp createAndroidWebAppResponse
res := s.DoRawWithHeaders("POST", "/api/latest/fleet/software/web_apps", body.Bytes(), http.StatusOK, headers)
err = json.NewDecoder(res.Body).Decode(&resp)
require.NoError(t, err)
webAppID := resp.AppStoreID
// add it to Fleet with configuration, will fail
res = s.Do("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
AppStoreID: webAppID, Platform: fleet.AndroidPlatform, Configuration: json.RawMessage(`{"key":"value"}`),
}, http.StatusUnprocessableEntity)
require.Contains(t, extractServerErrorText(res.Body), "Android web apps don't support configurations.")
// add it without configuration, will work
var addResp addAppStoreAppResponse
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
AppStoreID: webAppID, Platform: fleet.AndroidPlatform,
}, http.StatusOK, &addResp)
webAppTitleID := addResp.TitleID
// update it with configuration, will fail
res = s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", webAppTitleID),
&updateAppStoreAppRequest{Configuration: json.RawMessage(`{"key":"value"}`)},
http.StatusUnprocessableEntity)
require.Contains(t, extractServerErrorText(res.Body), "Android web apps don't support configurations.")
// update it without configuration, will work
var updateResp updateAppStoreAppResponse
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", webAppTitleID),
&updateAppStoreAppRequest{DisplayName: ptr.String("MyWebApp")},
http.StatusOK, &updateResp)
require.Equal(t, webAppID, updateResp.AppStoreApp.AdamID)
require.Equal(t, "MyWebApp", updateResp.AppStoreApp.DisplayName)
require.Equal(t, "abc1", updateResp.AppStoreApp.Name)
require.True(t, updateResp.AppStoreApp.SelfService)
require.Nil(t, updateResp.AppStoreApp.Configuration)
// batch-set with configuration, will fail
res = s.Do("POST", "/api/latest/fleet/software/app_store_apps/batch",
batchAssociateAppStoreAppsRequest{
DryRun: false,
Apps: []fleet.VPPBatchPayload{
{AppStoreID: webAppID, SelfService: true, Platform: fleet.AndroidPlatform, Configuration: json.RawMessage(`{"key":"value"}`)},
},
}, http.StatusUnprocessableEntity)
require.Contains(t, extractServerErrorText(res.Body), "Android web apps don't support configurations.")
// batch-set with multiple Android apps, with configuration on the webApp, will fail
res = s.Do("POST", "/api/latest/fleet/software/app_store_apps/batch",
batchAssociateAppStoreAppsRequest{
DryRun: false,
Apps: []fleet.VPPBatchPayload{
{AppStoreID: webAppID, SelfService: true, Platform: fleet.AndroidPlatform, Configuration: json.RawMessage(`{"key":"value"}`)},
{AppStoreID: "com.google.chrome", SelfService: true, Platform: fleet.AndroidPlatform},
},
}, http.StatusUnprocessableEntity)
require.Contains(t, extractServerErrorText(res.Body), "Android web apps don't support configurations.")
// batch-set without configuration, will work
var batchResp batchAssociateAppStoreAppsResponse
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps/batch",
batchAssociateAppStoreAppsRequest{
DryRun: false,
Apps: []fleet.VPPBatchPayload{
{AppStoreID: webAppID, SelfService: true, Platform: fleet.AndroidPlatform},
{AppStoreID: "com.google.chrome", SelfService: true, Platform: fleet.AndroidPlatform},
},
}, http.StatusOK, &batchResp)
require.Len(t, batchResp.Apps, 2)
}
+27 -6
View File
@@ -11046,27 +11046,48 @@ func (s *integrationTestSuite) TestMDMNotConfiguredEndpoints() {
h.OrbitNodeKey = &orbitKey
windowsOnly := windowsMDMConfigurationRequiredEndpoints()
androidOnly := androidMDMConfigurationRequiredEndpoints()
for _, route := range mdmConfigurationRequiredEndpoints() {
var expectedErr fleet.ErrWithStatusCode = fleet.ErrMDMNotConfigured
path := route.path
if slices.Contains(windowsOnly, path) {
expectedErr = fleet.ErrWindowsMDMNotConfigured
} else if slices.Contains(androidOnly, path) {
expectedErr = fleet.ErrAndroidMDMNotConfigured
}
if route.deviceAuthenticated {
path = fmt.Sprintf(path, tkn)
}
var params interface{}
if route.method == "POST" && route.path == "/api/fleet/orbit/setup_experience/status" {
// build the body of the request
var params any
var multipartBody *bytes.Buffer
var headers map[string]string
switch {
case route.method == "POST" && route.path == "/api/fleet/orbit/setup_experience/status":
params = getOrbitSetupExperienceStatusRequest{
OrbitNodeKey: *h.OrbitNodeKey,
}
}
// These routes don't require MDM because they can be used to change end-user auth, but they do require a license.
if route.method == "PATCH" && (route.path == "/api/latest/fleet/setup_experience" || route.path == "/api/latest/fleet/mdm/apple/setup") {
case route.method == "POST" && route.path == "/api/latest/fleet/software/web_apps":
multipartBody, headers = generateMultipartRequest(t, "", "", nil, s.token, map[string][]string{
"title": {"Test App"},
"url": {"https://example.com"},
})
case route.method == "PATCH" && (route.path == "/api/latest/fleet/setup_experience" || route.path == "/api/latest/fleet/mdm/apple/setup"):
// These routes don't require MDM because they can be used to change end-user auth, but they do require a license.
expectedErr = fleet.ErrMissingLicense
}
res := s.Do(route.method, path, params, expectedErr.StatusCode())
var res *http.Response
if multipartBody != nil {
res = s.DoRawWithHeaders(route.method, path, multipartBody.Bytes(), expectedErr.StatusCode(), headers)
} else {
res = s.Do(route.method, path, params, expectedErr.StatusCode())
}
errMsg := extractServerErrorText(res.Body)
assert.Contains(t, errMsg, expectedErr.Error(), fmt.Sprintf("%s %s", route.method, path))
}
+24 -5
View File
@@ -5685,28 +5685,47 @@ func (s *integrationEnterpriseTestSuite) TestMDMNotConfiguredEndpoints() {
h.OrbitNodeKey = &orbitKey
windowsOnly := windowsMDMConfigurationRequiredEndpoints()
androidOnly := androidMDMConfigurationRequiredEndpoints()
for _, route := range mdmConfigurationRequiredEndpoints() {
var expectedErr fleet.ErrWithStatusCode = fleet.ErrMDMNotConfigured
path := route.path
if slices.Contains(windowsOnly, path) {
expectedErr = fleet.ErrWindowsMDMNotConfigured
} else if slices.Contains(androidOnly, path) {
expectedErr = fleet.ErrAndroidMDMNotConfigured
}
if route.deviceAuthenticated {
path = fmt.Sprintf(path, tkn)
}
// build the body of the request
var params any
if route.method == "POST" && route.path == "/api/fleet/orbit/setup_experience/status" {
var multipartBody *bytes.Buffer
var headers map[string]string
switch {
case route.method == "POST" && route.path == "/api/fleet/orbit/setup_experience/status":
params = getOrbitSetupExperienceStatusRequest{
OrbitNodeKey: *h.OrbitNodeKey,
}
}
// These routes don't require MDM if you're only changing end-user auth, so we'll set something else to check.
if route.method == "PATCH" && (route.path == "/api/latest/fleet/setup_experience" || route.path == "/api/latest/fleet/mdm/apple/setup") {
case route.method == "POST" && route.path == "/api/latest/fleet/software/web_apps":
multipartBody, headers = generateMultipartRequest(t, "", "", nil, s.token, map[string][]string{
"title": {"Test App"},
"url": {"https://example.com"},
})
case route.method == "PATCH" && (route.path == "/api/latest/fleet/setup_experience" || route.path == "/api/latest/fleet/mdm/apple/setup"):
// These routes don't require MDM if you're only changing end-user auth, so we'll set something else to check.
params = fleet.MDMAppleSetupPayload{EnableReleaseDeviceManually: ptr.Bool(true)}
}
res := s.Do(route.method, path, params, expectedErr.StatusCode())
var res *http.Response
if multipartBody != nil {
res = s.DoRawWithHeaders(route.method, path, multipartBody.Bytes(), expectedErr.StatusCode(), headers)
} else {
res = s.Do(route.method, path, params, expectedErr.StatusCode())
}
errMsg := extractServerErrorText(res.Body)
assert.Contains(t, errMsg, expectedErr.Error(), fmt.Sprintf("%s %s", route.method, path))
}
@@ -21,7 +21,7 @@ func NewMDMConfigMiddleware(svc fleet.Service) *Middleware {
func (m *Middleware) VerifyAppleMDM() endpoint.Middleware {
return func(next endpoint.Endpoint) endpoint.Endpoint {
return func(ctx context.Context, req interface{}) (interface{}, error) {
return func(ctx context.Context, req any) (any, error) {
if err := m.svc.VerifyMDMAppleConfigured(ctx); err != nil {
return nil, err
}
@@ -35,7 +35,7 @@ func (m *Middleware) VerifyAppleMDM() endpoint.Middleware {
// This is used on API endpoints that are reused on Linux hosts (which don't require Apple MDM to be configured).
func (m *Middleware) VerifyAppleMDMOnMacOSHosts() endpoint.Middleware {
return func(next endpoint.Endpoint) endpoint.Endpoint {
return func(ctx context.Context, req interface{}) (interface{}, error) {
return func(ctx context.Context, req any) (any, error) {
host, ok := hostctx.FromContext(ctx)
if !ok {
return nil, ctxerr.Wrap(ctx, fleet.NewAuthRequiredError("internal error: missing host from request context"))
@@ -53,7 +53,7 @@ func (m *Middleware) VerifyAppleMDMOnMacOSHosts() endpoint.Middleware {
func (m *Middleware) VerifyWindowsMDM() endpoint.Middleware {
return func(next endpoint.Endpoint) endpoint.Endpoint {
return func(ctx context.Context, req interface{}) (interface{}, error) {
return func(ctx context.Context, req any) (any, error) {
if err := m.svc.VerifyMDMWindowsConfigured(ctx); err != nil {
return nil, err
}
@@ -63,9 +63,21 @@ func (m *Middleware) VerifyWindowsMDM() endpoint.Middleware {
}
}
func (m *Middleware) VerifyAndroidMDM() endpoint.Middleware {
return func(next endpoint.Endpoint) endpoint.Endpoint {
return func(ctx context.Context, req any) (any, error) {
if err := m.svc.VerifyMDMAndroidConfigured(ctx); err != nil {
return nil, err
}
return next(ctx, req)
}
}
}
func (m *Middleware) VerifyAnyMDM() endpoint.Middleware {
return func(next endpoint.Endpoint) endpoint.Endpoint {
return func(ctx context.Context, req interface{}) (interface{}, error) {
return func(ctx context.Context, req any) (any, error) {
if err := m.svc.VerifyAnyMDMConfigured(ctx); err != nil {
return nil, err
}
@@ -39,6 +39,14 @@ func (m *mockService) VerifyMDMWindowsConfigured(ctx context.Context) error {
return nil
}
// VerifyMDMAndroidConfigured marks whether Android MDM is enabled for the test.
func (m *mockService) VerifyMDMAndroidConfigured(ctx context.Context) error {
if !m.androidConfigured.Load() {
return fleet.ErrAndroidMDMNotConfigured
}
return nil
}
// VerifyAnyMDMConfigured is the mock implementation that mirrors the production
// VerifyAnyMDMConfigured service method, adding Android to the Apple/Windows check.
func (m *mockService) VerifyAnyMDMConfigured(ctx context.Context) error {
@@ -116,6 +124,40 @@ func TestWindowsMDMNotConfigured(t *testing.T) {
require.False(t, nextCalled)
}
func TestAndroidMDMConfigured(t *testing.T) {
svc := mockService{}
svc.androidConfigured.Store(true)
mw := NewMDMConfigMiddleware(&svc)
nextCalled := false
next := func(ctx context.Context, req any) (any, error) {
nextCalled = true
return struct{}{}, nil
}
f := mw.VerifyAndroidMDM()(next)
_, err := f(context.Background(), struct{}{})
require.NoError(t, err)
require.True(t, nextCalled)
}
func TestAndroidMDMNotConfigured(t *testing.T) {
svc := mockService{}
svc.androidConfigured.Store(false)
mw := NewMDMConfigMiddleware(&svc)
nextCalled := false
next := func(ctx context.Context, req any) (any, error) {
nextCalled = true
return struct{}{}, nil
}
f := mw.VerifyAndroidMDM()(next)
_, err := f(context.Background(), struct{}{})
require.ErrorIs(t, err, fleet.ErrAndroidMDMNotConfigured)
require.False(t, nextCalled)
}
// TestAnyMDMConfigured exercises the new middleware that recognizes Apple,
// Windows, or Android MDM individually or in combination.
func TestAnyMDMConfigured(t *testing.T) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

+1
View File
@@ -0,0 +1 @@
Not a .png
+7
View File
@@ -949,6 +949,7 @@ func mdmConfigurationRequiredEndpoints() []struct {
{"PATCH", "/api/latest/fleet/mdm/apple/setup", false, true},
{"PATCH", "/api/latest/fleet/setup_experience", false, true},
{"POST", "/api/fleet/orbit/setup_experience/status", false, true},
{"POST", "/api/latest/fleet/software/web_apps", false, true},
}
}
@@ -958,6 +959,12 @@ func windowsMDMConfigurationRequiredEndpoints() []string {
}
}
func androidMDMConfigurationRequiredEndpoints() []string {
return []string{
"/api/latest/fleet/software/web_apps",
}
}
// getURLSchemas returns a list of all valid URI schemas
func getURISchemas() []string {
return []string{
+75
View File
@@ -428,3 +428,78 @@ func (svc *Service) DeleteVPPToken(ctx context.Context, tokenID uint) error {
return fleet.ErrMissingLicense
}
////////////////////////////////////////////////////////////////////////////////
// POST /api/_version_/software/web_apps
////////////////////////////////////////////////////////////////////////////////
type createAndroidWebAppRequest struct {
Title string
URL string
Icon *multipart.FileHeader
}
func (createAndroidWebAppRequest) DecodeRequest(ctx context.Context, r *http.Request) (any, error) {
decoded := createAndroidWebAppRequest{}
err := r.ParseMultipartForm(platform_http.MaxMultipartFormSize)
if err != nil {
return nil, &fleet.BadRequestError{
Message: "failed to parse multipart form",
InternalErr: err,
}
}
title := r.FormValue("title")
if title == "" {
return nil, &fleet.BadRequestError{Message: "title multipart field is required"}
}
decoded.Title = title
url := r.FormValue("url")
if url == "" {
return nil, &fleet.BadRequestError{Message: "url multipart field is required"}
}
decoded.URL = url
if len(r.MultipartForm.File["icon"]) > 0 {
decoded.Icon = r.MultipartForm.File["icon"][0]
}
return &decoded, nil
}
type createAndroidWebAppResponse struct {
AppStoreID string `json:"app_store_id"`
Err error `json:"error,omitempty"`
}
func (r createAndroidWebAppResponse) Error() error { return r.Err }
func createAndroidWebAppEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*createAndroidWebAppRequest)
var iconReader io.Reader
if req.Icon != nil {
f, err := req.Icon.Open()
if err != nil {
return createAndroidWebAppResponse{Err: err}, nil
}
defer f.Close()
iconReader = f
}
appID, err := svc.CreateAndroidWebApp(ctx, req.Title, req.URL, iconReader)
if err != nil {
return createAndroidWebAppResponse{Err: err}, nil
}
return createAndroidWebAppResponse{AppStoreID: appID}, nil
}
func (svc *Service) CreateAndroidWebApp(ctx context.Context, title, startURL string, icon io.Reader) (string, error) {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return "", fleet.ErrMissingLicense
}
+67 -34
View File
@@ -2,25 +2,51 @@ package service
import (
"context"
"log/slog"
"os"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/android"
android_mock "github.com/fleetdm/fleet/v4/server/mdm/android/mock"
android_service "github.com/fleetdm/fleet/v4/server/mdm/android/service"
"github.com/fleetdm/fleet/v4/server/mock"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/service/modules/activities"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/require"
"google.golang.org/api/androidmanagement/v1"
)
func TestVPPAuth(t *testing.T) {
ds := new(mock.Store)
license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)}
assets := map[fleet.MDMAssetName]fleet.MDMConfigAsset{
fleet.MDMAssetAndroidFleetServerSecret: {Value: []byte("secret")},
}
ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName,
_ sqlx.QueryerContext,
) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
return assets, nil
}
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license})
license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)}
androidMockClient := &android_mock.Client{}
androidMockClient.SetAuthenticationSecretFunc = func(secret string) error { return nil }
androidMockClient.EnterprisesWebAppsCreateFunc = func(ctx context.Context, enterpriseName string, app *androidmanagement.WebApp) (*androidmanagement.WebApp, error) {
return &androidmanagement.WebApp{Name: "webapp1"}, nil
}
activityModule := activities.NewActivityModule()
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
androidSvc, err := android_service.NewServiceWithClient(logger, ds, androidMockClient, "test-private-key", ds, activityModule, config.AndroidAgentConfig{})
require.NoError(t, err)
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, androidModule: androidSvc})
// use a custom implementation of checkAuthErr as the service call will fail
// with a different error for in case of authorization success and the
@@ -35,39 +61,40 @@ func TestVPPAuth(t *testing.T) {
}
testCases := []struct {
name string
user *fleet.User
teamID *uint
shouldFailRead bool
shouldFailWrite bool
name string
user *fleet.User
teamID *uint
shouldFailRead bool
shouldFailWrite bool
shouldFailCreateWebApp bool
}{
{"no role no team", test.UserNoRoles, nil, true, true},
{"no role team", test.UserNoRoles, ptr.Uint(1), true, true},
{"global admin no team", test.UserAdmin, nil, false, false},
{"global admin team", test.UserAdmin, ptr.Uint(1), false, false},
{"global maintainer no team", test.UserMaintainer, nil, false, false},
{"global maintainer team", test.UserMaintainer, ptr.Uint(1), false, false},
{"global observer no team", test.UserObserver, nil, true, true},
{"global observer team", test.UserObserver, ptr.Uint(1), true, true},
{"global observer+ no team", test.UserObserverPlus, nil, true, true},
{"global observer+ team", test.UserObserverPlus, ptr.Uint(1), true, true},
{"global gitops no team", test.UserGitOps, nil, true, false},
{"global gitops team", test.UserGitOps, ptr.Uint(1), true, false},
{"team admin no team", test.UserTeamAdminTeam1, nil, true, true},
{"team admin team", test.UserTeamAdminTeam1, ptr.Uint(1), false, false},
{"team admin other team", test.UserTeamAdminTeam2, ptr.Uint(1), true, true},
{"team maintainer no team", test.UserTeamMaintainerTeam1, nil, true, true},
{"team maintainer team", test.UserTeamMaintainerTeam1, ptr.Uint(1), false, false},
{"team maintainer other team", test.UserTeamMaintainerTeam2, ptr.Uint(1), true, true},
{"team observer no team", test.UserTeamObserverTeam1, nil, true, true},
{"team observer team", test.UserTeamObserverTeam1, ptr.Uint(1), true, true},
{"team observer other team", test.UserTeamObserverTeam2, ptr.Uint(1), true, true},
{"team observer+ no team", test.UserTeamObserverPlusTeam1, nil, true, true},
{"team observer+ team", test.UserTeamObserverPlusTeam1, ptr.Uint(1), true, true},
{"team observer+ other team", test.UserTeamObserverPlusTeam2, ptr.Uint(1), true, true},
{"team gitops no team", test.UserTeamGitOpsTeam1, nil, true, true},
{"team gitops team", test.UserTeamGitOpsTeam1, ptr.Uint(1), true, false},
{"team gitops other team", test.UserTeamGitOpsTeam2, ptr.Uint(1), true, true},
{"no role no team", test.UserNoRoles, nil, true, true, true},
{"no role team", test.UserNoRoles, ptr.Uint(1), true, true, true},
{"global admin no team", test.UserAdmin, nil, false, false, false},
{"global admin team", test.UserAdmin, ptr.Uint(1), false, false, false},
{"global maintainer no team", test.UserMaintainer, nil, false, false, false},
{"global maintainer team", test.UserMaintainer, ptr.Uint(1), false, false, false},
{"global observer no team", test.UserObserver, nil, true, true, true},
{"global observer team", test.UserObserver, ptr.Uint(1), true, true, true},
{"global observer+ no team", test.UserObserverPlus, nil, true, true, true},
{"global observer+ team", test.UserObserverPlus, ptr.Uint(1), true, true, true},
{"global gitops no team", test.UserGitOps, nil, true, false, false},
{"global gitops team", test.UserGitOps, ptr.Uint(1), true, false, false},
{"team admin no team", test.UserTeamAdminTeam1, nil, true, true, false},
{"team admin team", test.UserTeamAdminTeam1, ptr.Uint(1), false, false, false},
{"team admin other team", test.UserTeamAdminTeam2, ptr.Uint(1), true, true, false},
{"team maintainer no team", test.UserTeamMaintainerTeam1, nil, true, true, false},
{"team maintainer team", test.UserTeamMaintainerTeam1, ptr.Uint(1), false, false, false},
{"team maintainer other team", test.UserTeamMaintainerTeam2, ptr.Uint(1), true, true, false},
{"team observer no team", test.UserTeamObserverTeam1, nil, true, true, true},
{"team observer team", test.UserTeamObserverTeam1, ptr.Uint(1), true, true, true},
{"team observer other team", test.UserTeamObserverTeam2, ptr.Uint(1), true, true, true},
{"team observer+ no team", test.UserTeamObserverPlusTeam1, nil, true, true, true},
{"team observer+ team", test.UserTeamObserverPlusTeam1, ptr.Uint(1), true, true, true},
{"team observer+ other team", test.UserTeamObserverPlusTeam2, ptr.Uint(1), true, true, true},
{"team gitops no team", test.UserTeamGitOpsTeam1, nil, true, true, false},
{"team gitops team", test.UserTeamGitOpsTeam1, ptr.Uint(1), true, false, false},
{"team gitops other team", test.UserTeamGitOpsTeam2, ptr.Uint(1), true, true, false},
}
for _, tt := range testCases {
@@ -88,6 +115,9 @@ func TestVPPAuth(t *testing.T) {
ds.GetVPPTokenByTeamIDFunc = func(ctx context.Context, teamID *uint) (*fleet.VPPTokenDB, error) {
return &fleet.VPPTokenDB{ID: 1, OrgName: "org", Teams: []fleet.TeamTuple{{ID: 1}}}, nil
}
ds.GetEnterpriseFunc = func(ctx context.Context) (*android.Enterprise, error) {
return &android.Enterprise{}, nil
}
// Note: these calls always return an error because they're attempting to unmarshal a
// non-existent VPP token.
@@ -104,6 +134,9 @@ func TestVPPAuth(t *testing.T) {
} else {
checkAuthErr(t, tt.shouldFailWrite, err)
}
_, err = svc.CreateAndroidWebApp(ctx, "test", "http://example.com", nil)
checkAuthErr(t, tt.shouldFailCreateWebApp, err)
})
}
}