<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #35799 Loadtest results for 100K hosts and 300K software titles. ``` === Performance Test Results: No team_id (all teams) === Description Average Worst Results ----------- ------- ----- ------- Page 0, hosts_count DESC 229ms 241ms 20 items Page 0, hosts_count ASC 203ms 211ms 20 items Page 1, hosts_count DESC 339ms 423ms 20 items Page 1000, hosts_count DESC 202ms 219ms 20 items 100 per_page, hosts_count DESC 620ms 708ms 100 items Default sort (no order params) 229ms 245ms 20 items Order by name ASC, page 0 4.642s 4.785s 20 items Order by name ASC, page 1000 6.418s 6.771s 20 items Vulnerable only 3.431s 3.496s 20 items Search 'chrome' 9.6s 10.111s 20 items Known exploit filter 9.792s 10.102s 20 items Min CVSS score 7.0 12.368s 12.665s 20 items CVSS range 7.0-9.0 12.221s 12.523s 20 items Available for install 87ms 93ms NO RESULTS Self-service only 4.46s 4.757s 20 items === Performance Test Results: team_id=0 (no team / unassigned) === Description Average Worst Results ----------- ------- ----- ------- Page 0, hosts_count DESC 378ms 404ms 20 items Page 0, hosts_count ASC 339ms 345ms 20 items Page 1, hosts_count DESC 478ms 513ms 20 items Page 1000, hosts_count DESC 398ms 417ms 20 items 100 per_page, hosts_count DESC 864ms 1.025s 100 items Default sort (no order params) 399ms 411ms 20 items Order by name ASC, page 0 5.346s 5.41s 20 items Order by name ASC, page 1000 7.444s 7.615s 20 items Search 'chrome' 9.051s 9.245s 20 items Known exploit filter 10.511s 10.884s 20 items Min CVSS score 7.0 16.589s 16.701s 20 items CVSS range 7.0-9.0 15.878s 15.999s 20 items Available for install 1.394s 1.429s 1 items Self-service only 1.4s 1.456s 1 items ``` Documented in the issue: The fix includes a small behavior change. The default primary sort of /software/titles remains host_counts, but the secondary sort is now software_title_id and not name. This was necessary to optimize the endpoint. This means that if you have 1 host in your fleet, the software page will not show the software titles ordered by name anymore. For large fleets, this does not matter since all titles generally have different host counts. # Checklist for submitter - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually ## Database migrations - [x] Checked schema for all modified table for columns that will auto-update timestamps during migration. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Refactor** * Optimized the software titles endpoint for improved query performance and faster data retrieval. * Enhanced database efficiency when retrieving software information, resulting in better overall system responsiveness and reduced query times. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1108 lines
47 KiB
Go
1108 lines
47 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
"github.com/fleetdm/fleet/v4/server/mdm/android"
|
|
android_service "github.com/fleetdm/fleet/v4/server/mdm/android/service"
|
|
"github.com/fleetdm/fleet/v4/server/mdm/android/service/androidmgmt"
|
|
"github.com/fleetdm/fleet/v4/server/ptr"
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"google.golang.org/api/androidmanagement/v1"
|
|
)
|
|
|
|
func (s *integrationMDMTestSuite) TestAndroidAppsSelfService() {
|
|
ctx := context.Background()
|
|
t := s.T()
|
|
|
|
s.setVPPTokenForTeam(0)
|
|
appConf, err := s.ds.AppConfig(context.Background())
|
|
require.NoError(s.T(), err)
|
|
appConf.MDM.AndroidEnabledAndConfigured = false
|
|
err = s.ds.SaveAppConfig(context.Background(), appConf)
|
|
require.NoError(s.T(), err)
|
|
|
|
// Adding android app before android MDM is turned on should fail
|
|
var addAppResp addAppStoreAppResponse
|
|
s.DoJSON(
|
|
"POST",
|
|
"/api/latest/fleet/software/app_store_apps",
|
|
&addAppStoreAppRequest{AppStoreID: "com.should.fail", Platform: fleet.AndroidPlatform},
|
|
http.StatusBadRequest,
|
|
&addAppResp,
|
|
)
|
|
|
|
s.enableAndroidMDM(t)
|
|
|
|
// Android MDM setup
|
|
androidApp := &fleet.VPPApp{
|
|
VPPAppTeam: fleet.VPPAppTeam{
|
|
VPPAppID: fleet.VPPAppID{
|
|
AdamID: "com.whatsapp",
|
|
Platform: fleet.AndroidPlatform,
|
|
},
|
|
},
|
|
Name: "WhatsApp",
|
|
BundleIdentifier: "com.whatsapp",
|
|
IconURL: "https://example.com/images/2",
|
|
}
|
|
|
|
// Invalid application ID format: should fail
|
|
r := s.Do(
|
|
"POST",
|
|
"/api/latest/fleet/software/app_store_apps",
|
|
&addAppStoreAppRequest{AppStoreID: "thisisnotanappid", Platform: fleet.AndroidPlatform},
|
|
http.StatusUnprocessableEntity,
|
|
)
|
|
require.Contains(t, extractServerErrorText(r.Body), "Application ID must be a valid Android application ID")
|
|
|
|
// Fleet agent app: should fail (cannot be added manually)
|
|
for _, fleetAgentPkg := range []string{
|
|
"com.fleetdm.agent",
|
|
"com.fleetdm.agent.pingali",
|
|
"com.fleetdm.agent.private.testuser",
|
|
} {
|
|
r = s.Do(
|
|
"POST",
|
|
"/api/latest/fleet/software/app_store_apps",
|
|
&addAppStoreAppRequest{AppStoreID: fleetAgentPkg, Platform: fleet.AndroidPlatform},
|
|
http.StatusUnprocessableEntity,
|
|
)
|
|
require.Contains(t, extractServerErrorText(r.Body), "The Fleet agent cannot be added manually",
|
|
"expected Fleet Agent package %s to be blocked", fleetAgentPkg)
|
|
}
|
|
|
|
// Missing platform: should fail
|
|
r = s.Do(
|
|
"POST",
|
|
"/api/latest/fleet/software/app_store_apps",
|
|
&addAppStoreAppRequest{AppStoreID: "com.valid.app.id"},
|
|
http.StatusUnprocessableEntity,
|
|
)
|
|
s.Assert().Contains(extractServerErrorText(r.Body), "Couldn't add software. com.valid.app.id isn't available in Apple Business Manager or Play Store. Please purchase a license in Apple Business Manager or find the app in Play Store and try again.")
|
|
|
|
// Valid application ID format, but app isn't found: should fail
|
|
// Update mock to return a 404
|
|
s.androidAPIClient.EnterprisesApplicationsFunc = func(ctx context.Context, enterpriseName string, packageName string) (*androidmanagement.Application, error) {
|
|
return nil, ¬FoundError{}
|
|
}
|
|
|
|
r = s.Do(
|
|
"POST",
|
|
"/api/latest/fleet/software/app_store_apps",
|
|
&addAppStoreAppRequest{AppStoreID: "com.app.id.not.found", Platform: fleet.AndroidPlatform},
|
|
http.StatusUnprocessableEntity,
|
|
)
|
|
s.Assert().Contains(extractServerErrorText(r.Body), "Couldn't add software. The application ID isn't available in Play Store. Please find ID on the Play Store and try again.")
|
|
|
|
amapiConfig := struct {
|
|
AppIDsToNames map[string]string
|
|
EnterprisesPoliciesPatchValidator func(policyName string, policy *androidmanagement.Policy, opts androidmgmt.PoliciesPatchOpts)
|
|
}{
|
|
AppIDsToNames: map[string]string{},
|
|
EnterprisesPoliciesPatchValidator: func(policyName string, policy *androidmanagement.Policy, opts androidmgmt.PoliciesPatchOpts) {},
|
|
}
|
|
|
|
s.androidAPIClient.EnterprisesApplicationsFunc = func(ctx context.Context, enterpriseName string, packageName string) (*androidmanagement.Application, error) {
|
|
title := amapiConfig.AppIDsToNames[packageName]
|
|
|
|
return &androidmanagement.Application{IconUrl: "https://example.com/1.jpg", Title: title}, nil
|
|
}
|
|
|
|
s.androidAPIClient.EnterprisesPoliciesPatchFunc = func(ctx context.Context, policyName string, policy *androidmanagement.Policy, opts androidmgmt.PoliciesPatchOpts) (*androidmanagement.Policy, error) {
|
|
amapiConfig.EnterprisesPoliciesPatchValidator(policyName, policy, opts)
|
|
|
|
return &androidmanagement.Policy{}, nil
|
|
}
|
|
|
|
// Valid application ID format, but wrong platform specified: should fail
|
|
r = s.Do(
|
|
"POST",
|
|
"/api/latest/fleet/software/app_store_apps",
|
|
&addAppStoreAppRequest{AppStoreID: "com.valid", Platform: fleet.MacOSPlatform},
|
|
http.StatusUnprocessableEntity,
|
|
)
|
|
require.Contains(t, extractServerErrorText(r.Body), "Couldn't add software. com.valid isn't available in Apple Business Manager or Play Store. Please purchase a license in Apple Business Manager or find the app in Play Store and try again.")
|
|
|
|
// Add Android app
|
|
s.DoJSON(
|
|
"POST",
|
|
"/api/latest/fleet/software/app_store_apps",
|
|
&addAppStoreAppRequest{AppStoreID: androidApp.AdamID, Platform: fleet.AndroidPlatform},
|
|
http.StatusOK,
|
|
&addAppResp,
|
|
)
|
|
|
|
// self_service is coerced to be true
|
|
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
|
var selfService bool
|
|
err := sqlx.GetContext(ctx, q, &selfService, "SELECT self_service FROM vpp_apps_teams WHERE adam_id = ?", androidApp.AdamID)
|
|
s.Require().NoError(err)
|
|
s.Assert().True(selfService)
|
|
return nil
|
|
})
|
|
|
|
secrets, err := s.ds.GetEnrollSecrets(ctx, nil)
|
|
require.NoError(t, err)
|
|
require.Len(t, secrets, 1)
|
|
|
|
assets, err := s.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetAndroidPubSubToken}, nil)
|
|
require.NoError(t, err)
|
|
pubsubToken := assets[fleet.MDMAssetAndroidPubSubToken]
|
|
require.NotEmpty(t, pubsubToken.Value)
|
|
|
|
deviceID1 := createAndroidDeviceID("test-android")
|
|
deviceID2 := createAndroidDeviceID("test-android-2")
|
|
|
|
enterpriseSpecificID1 := strings.ToUpper(uuid.New().String())
|
|
enterpriseSpecificID2 := strings.ToUpper(uuid.New().String())
|
|
var req android_service.PubSubPushRequest
|
|
for _, d := range []struct {
|
|
id string
|
|
esi string
|
|
}{{deviceID1, enterpriseSpecificID1}, {deviceID2, enterpriseSpecificID2}} {
|
|
enrollmentMessage := enrollmentMessageWithEnterpriseSpecificID(
|
|
t,
|
|
androidmanagement.Device{
|
|
Name: d.id,
|
|
EnrollmentTokenData: fmt.Sprintf(`{"EnrollSecret": "%s"}`, secrets[0].Secret),
|
|
},
|
|
d.esi,
|
|
)
|
|
|
|
req = android_service.PubSubPushRequest{
|
|
PubSubMessage: *enrollmentMessage,
|
|
}
|
|
|
|
s.Do("POST", "/api/v1/fleet/android_enterprise/pubsub", &req, http.StatusOK, "token", string(pubsubToken.Value))
|
|
}
|
|
|
|
var hosts listHostsResponse
|
|
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &hosts)
|
|
|
|
assert.Len(t, hosts.Hosts, 2)
|
|
|
|
host1 := hosts.Hosts[0]
|
|
assert.Equal(t, host1.Platform, string(fleet.AndroidPlatform))
|
|
|
|
// Should see it in host software library
|
|
getHostSw := getHostSoftwareResponse{}
|
|
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host1.ID), nil, http.StatusOK, &getHostSw, "available_for_install", "true")
|
|
assert.Len(t, getHostSw.Software, 1)
|
|
s.Assert().NotNil(getHostSw.Software[0].AppStoreApp)
|
|
s.Assert().Equal(androidApp.AdamID, getHostSw.Software[0].AppStoreApp.AppStoreID)
|
|
|
|
// Should see it in software titles
|
|
err = s.ds.SyncHostsSoftware(context.Background(), time.Now())
|
|
require.NoError(t, err)
|
|
err = s.ds.SyncHostsSoftwareTitles(context.Background(), time.Now())
|
|
require.NoError(t, err)
|
|
|
|
var listSWTitles listSoftwareTitlesResponse
|
|
s.DoJSON("GET", "/api/latest/fleet/software/titles", nil, http.StatusOK, &listSWTitles, "team_id", fmt.Sprint(0))
|
|
|
|
s.Assert().Len(listSWTitles.SoftwareTitles, 1)
|
|
s.Assert().Equal(androidApp.AdamID, listSWTitles.SoftwareTitles[0].AppStoreApp.AppStoreID)
|
|
s.Assert().Empty(listSWTitles.SoftwareTitles[0].AppStoreApp.Version)
|
|
|
|
// Google AMAPI hasn't been hit yet
|
|
s.Assert().False(s.androidAPIClient.EnterprisesPoliciesModifyPolicyApplicationsFuncInvoked)
|
|
|
|
// Run worker, should run the job that assigns the app to the host's MDM policy
|
|
s.runWorkerUntilDone()
|
|
|
|
// Should have hit the android API endpoint
|
|
s.Assert().True(s.androidAPIClient.EnterprisesPoliciesModifyPolicyApplicationsFuncInvoked)
|
|
s.androidAPIClient.EnterprisesPoliciesModifyPolicyApplicationsFuncInvoked = false
|
|
|
|
s.DoJSON(
|
|
"PATCH",
|
|
fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", getHostSw.Software[0].ID),
|
|
&updateAppStoreAppRequest{SelfService: ptr.Bool(false)},
|
|
http.StatusOK,
|
|
&addAppResp,
|
|
)
|
|
|
|
// Even though we sent self_service: false, self_service remains true
|
|
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
|
var selfService bool
|
|
err := sqlx.GetContext(ctx, q, &selfService, "SELECT self_service FROM vpp_apps_teams WHERE adam_id = ?", getHostSw.Software[0].AppStoreApp.AppStoreID)
|
|
s.Require().NoError(err)
|
|
s.Assert().True(selfService)
|
|
return nil
|
|
})
|
|
|
|
// Add some apps to a different team. They shouldn't be sent to our existing host
|
|
var newTeamResp teamResponse
|
|
s.DoJSON("POST", "/api/latest/fleet/teams", &createTeamRequest{TeamPayload: fleet.TeamPayload{Name: ptr.String("Team 1")}}, http.StatusOK, &newTeamResp)
|
|
team := newTeamResp.Team
|
|
// Add Android app
|
|
androidAppNewTeam := &fleet.VPPApp{
|
|
VPPAppTeam: fleet.VPPAppTeam{
|
|
VPPAppID: fleet.VPPAppID{
|
|
AdamID: "com.my.cool.app",
|
|
Platform: fleet.AndroidPlatform,
|
|
},
|
|
},
|
|
Name: "My cool app",
|
|
BundleIdentifier: "com.my.cool.app",
|
|
IconURL: "https://example.com/images/3",
|
|
}
|
|
s.DoJSON(
|
|
"POST",
|
|
"/api/latest/fleet/software/app_store_apps",
|
|
&addAppStoreAppRequest{AppStoreID: androidAppNewTeam.AdamID, Platform: fleet.AndroidPlatform, TeamID: &team.ID},
|
|
http.StatusOK,
|
|
&addAppResp,
|
|
)
|
|
|
|
// New app should not show up in "No team" library
|
|
s.DoJSON("GET", "/api/latest/fleet/software/titles", nil, http.StatusOK, &listSWTitles, "team_id", fmt.Sprint(0))
|
|
s.Assert().Len(listSWTitles.SoftwareTitles, 1)
|
|
s.Assert().Equal(androidApp.AdamID, listSWTitles.SoftwareTitles[0].AppStoreApp.AppStoreID) // just the app we had before
|
|
s.Assert().Empty(listSWTitles.SoftwareTitles[0].AppStoreApp.Version)
|
|
|
|
// New app SHOULD show up in our new team library
|
|
s.DoJSON("GET", "/api/latest/fleet/software/titles", nil, http.StatusOK, &listSWTitles, "team_id", fmt.Sprint(team.ID))
|
|
s.Assert().Len(listSWTitles.SoftwareTitles, 1)
|
|
s.Assert().Equal(androidAppNewTeam.AdamID, listSWTitles.SoftwareTitles[0].AppStoreApp.AppStoreID)
|
|
s.Assert().Empty(listSWTitles.SoftwareTitles[0].AppStoreApp.Version)
|
|
|
|
androidAppNewTeam2 := &fleet.VPPApp{
|
|
VPPAppTeam: fleet.VPPAppTeam{
|
|
VPPAppID: fleet.VPPAppID{
|
|
AdamID: "com.my.cool.app.two",
|
|
Platform: fleet.AndroidPlatform,
|
|
},
|
|
},
|
|
Name: "My cool app 2",
|
|
BundleIdentifier: "com.my.cool.app.two",
|
|
IconURL: "https://example.com/images/4",
|
|
}
|
|
|
|
amapiConfig.AppIDsToNames[androidAppNewTeam2.AdamID] = androidAppNewTeam2.Name
|
|
|
|
s.DoJSON(
|
|
"POST",
|
|
"/api/latest/fleet/software/app_store_apps",
|
|
&addAppStoreAppRequest{AppStoreID: androidAppNewTeam2.AdamID, Platform: fleet.AndroidPlatform, TeamID: &team.ID},
|
|
http.StatusOK,
|
|
&addAppResp,
|
|
)
|
|
|
|
s.DoJSON("GET", "/api/latest/fleet/software/titles", nil, http.StatusOK, &listSWTitles, "team_id", fmt.Sprint(team.ID))
|
|
s.Assert().Len(listSWTitles.SoftwareTitles, 2)
|
|
s.Assert().True(slices.ContainsFunc(listSWTitles.SoftwareTitles, func(t fleet.SoftwareTitleListResult) bool {
|
|
return t.AppStoreApp.AppStoreID == androidAppNewTeam.AdamID || t.AppStoreApp.AppStoreID == androidAppNewTeam2.AdamID
|
|
}))
|
|
|
|
s.lastActivityMatches(fleet.ActivityAddedAppStoreApp{}.ActivityName(),
|
|
fmt.Sprintf(`{"team_name": "%s", "fleet_name": "%s", "software_title": "%s", "software_title_id": %d, "app_store_id": "%s", "team_id": %s, "fleet_id": %s, "platform": "%s", "self_service": true}`,
|
|
team.Name, team.Name, androidAppNewTeam2.Name, addAppResp.TitleID, androidAppNewTeam2.AdamID, fmt.Sprint(team.ID), fmt.Sprint(team.ID), androidAppNewTeam2.Platform), 0)
|
|
|
|
s.androidAPIClient.EnterprisesPoliciesPatchFuncInvoked = false
|
|
s.runWorkerUntilDone()
|
|
// We shouldn't have hit the AMAPI, since there are no hosts in the team
|
|
s.Assert().False(s.androidAPIClient.EnterprisesPoliciesModifyPolicyApplicationsFuncInvoked)
|
|
s.Assert().False(s.androidAPIClient.EnterprisesPoliciesPatchFuncInvoked)
|
|
|
|
amapiConfig.EnterprisesPoliciesPatchValidator = func(policyName string, policy *androidmanagement.Policy, opts androidmgmt.PoliciesPatchOpts) {
|
|
var appIDs []string
|
|
for _, a := range policy.Applications {
|
|
appIDs = append(appIDs, a.PackageName)
|
|
}
|
|
|
|
s.Assert().ElementsMatch(appIDs, []string{androidAppNewTeam.AdamID, androidAppNewTeam2.AdamID, "com.fleetdm.agent"})
|
|
s.Assert().Contains(policyName, host1.UUID)
|
|
}
|
|
|
|
// Transfer a host to the team
|
|
s.DoJSON("POST", "/api/latest/fleet/hosts/transfer", addHostsToTeamRequest{
|
|
TeamID: &team.ID,
|
|
HostIDs: []uint{host1.ID},
|
|
}, http.StatusOK, &addHostsToTeamResponse{})
|
|
|
|
s.runWorkerUntilDone()
|
|
s.Assert().True(s.androidAPIClient.EnterprisesPoliciesPatchFuncInvoked)
|
|
|
|
// Transfer host back to "No team"
|
|
s.DoJSON("POST", "/api/latest/fleet/hosts/transfer", addHostsToTeamRequest{
|
|
TeamID: nil,
|
|
HostIDs: []uint{host1.ID},
|
|
}, http.StatusOK, &addHostsToTeamResponse{})
|
|
|
|
// =========================================
|
|
// Android app configurations
|
|
// =========================================
|
|
|
|
// Title with no configuration should omit it from response
|
|
var getAppResp map[string]any
|
|
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", addAppResp.TitleID), &getSoftwareTitleRequest{
|
|
ID: addAppResp.TitleID,
|
|
TeamID: nil,
|
|
}, http.StatusOK, &getAppResp)
|
|
require.Nil(t, getAppResp["configuration"])
|
|
|
|
// Android app with configuration
|
|
appConfiguration := json.RawMessage(`{"workProfileWidgets": "WORK_PROFILE_WIDGETS_ALLOWED"}`)
|
|
androidAppWithConfig := &fleet.VPPApp{
|
|
VPPAppTeam: fleet.VPPAppTeam{
|
|
VPPAppID: fleet.VPPAppID{
|
|
AdamID: "com.fooooooo",
|
|
Platform: fleet.AndroidPlatform,
|
|
},
|
|
Configuration: appConfiguration,
|
|
},
|
|
Name: "foo",
|
|
BundleIdentifier: "com.fooooooo",
|
|
IconURL: "https://example.com/images/2",
|
|
}
|
|
|
|
amapiConfig.AppIDsToNames[androidAppWithConfig.AdamID] = androidAppWithConfig.Name
|
|
|
|
// Add Android app
|
|
var appWithConfigResp addAppStoreAppResponse
|
|
s.DoJSON(
|
|
"POST",
|
|
"/api/latest/fleet/software/app_store_apps",
|
|
&addAppStoreAppRequest{
|
|
AppStoreID: androidAppWithConfig.AdamID,
|
|
Platform: androidAppWithConfig.VPPAppID.Platform,
|
|
Configuration: androidAppWithConfig.Configuration,
|
|
},
|
|
http.StatusOK,
|
|
&appWithConfigResp,
|
|
)
|
|
|
|
// Verify that activity includes configuration
|
|
s.lastActivityMatches(fleet.ActivityAddedAppStoreApp{}.ActivityName(),
|
|
fmt.Sprintf(`{"team_name": "%s", "fleet_name": "%s", "software_title": "%s", "software_title_id": %d, "app_store_id": "%s", "team_id": %s, "fleet_id": %s, "platform": "%s", "self_service": true,"configuration": %s}`,
|
|
fleet.TeamNameNoTeam, fleet.TeamNameNoTeam, androidAppWithConfig.Name, appWithConfigResp.TitleID, androidAppWithConfig.AdamID, "null", "null", androidAppWithConfig.Platform, androidAppWithConfig.Configuration), 0)
|
|
|
|
// Should see it in host software library
|
|
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host1.ID), nil, http.StatusOK, &getHostSw, "available_for_install", "true")
|
|
assert.Len(t, getHostSw.Software, 2)
|
|
s.Assert().NotNil(getHostSw.Software[1].AppStoreApp)
|
|
s.Assert().Equal(androidAppWithConfig.AdamID, getHostSw.Software[1].AppStoreApp.AppStoreID)
|
|
|
|
// Edit app without changing configuration
|
|
s.DoJSON(
|
|
"PATCH",
|
|
fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", appWithConfigResp.TitleID),
|
|
&updateAppStoreAppRequest{},
|
|
http.StatusOK,
|
|
&addAppResp,
|
|
)
|
|
s.lastActivityMatches(fleet.ActivityEditedAppStoreApp{}.ActivityName(), "", 0)
|
|
|
|
var titleWithConfigResp getSoftwareTitleResponse
|
|
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", appWithConfigResp.TitleID), &getSoftwareTitleRequest{
|
|
ID: appWithConfigResp.TitleID,
|
|
TeamID: nil,
|
|
}, http.StatusOK, &titleWithConfigResp)
|
|
|
|
require.Contains(t, string(titleWithConfigResp.SoftwareTitle.AppStoreApp.Configuration), "workProfileWidgets")
|
|
|
|
// Edit app and change configuration
|
|
newConfig := json.RawMessage(`{"managedConfiguration": {"key": "value"}}`)
|
|
s.DoJSON(
|
|
"PATCH",
|
|
fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", appWithConfigResp.TitleID),
|
|
&updateAppStoreAppRequest{
|
|
Configuration: newConfig,
|
|
},
|
|
http.StatusOK,
|
|
&addAppResp,
|
|
)
|
|
|
|
// Verify that configuration changed and last activity is correct
|
|
s.lastActivityMatches(fleet.ActivityEditedAppStoreApp{}.ActivityName(),
|
|
fmt.Sprintf(`{"team_name": "%s", "fleet_name": "%s", "software_title": "%s", "software_icon_url":"https://example.com/1.jpg", "software_title_id": %d, "app_store_id": "%s", "team_id": %s, "fleet_id": %s, "software_display_name":"", "platform": "%s", "self_service": true,"configuration": %s}`,
|
|
"", "", androidAppWithConfig.Name, appWithConfigResp.TitleID, androidAppWithConfig.AdamID, "null", "null", androidAppWithConfig.Platform, newConfig), 0)
|
|
}
|
|
|
|
func (s *integrationMDMTestSuite) TestAndroidSetupExperienceSoftware() {
|
|
t := s.T()
|
|
s.enableAndroidMDM(t)
|
|
|
|
app1 := &fleet.VPPApp{
|
|
VPPAppTeam: fleet.VPPAppTeam{
|
|
VPPAppID: fleet.VPPAppID{
|
|
AdamID: "com.test1",
|
|
Platform: fleet.AndroidPlatform,
|
|
},
|
|
},
|
|
Name: "Test1",
|
|
BundleIdentifier: "com.test1",
|
|
IconURL: "https://example.com/1",
|
|
}
|
|
app2 := &fleet.VPPApp{
|
|
VPPAppTeam: fleet.VPPAppTeam{
|
|
VPPAppID: fleet.VPPAppID{
|
|
AdamID: "com.test2",
|
|
Platform: fleet.AndroidPlatform,
|
|
},
|
|
},
|
|
Name: "Test2",
|
|
BundleIdentifier: "com.test2",
|
|
IconURL: "https://example.com/2",
|
|
}
|
|
|
|
androidApps := []*fleet.VPPApp{app1, app2}
|
|
s.androidAPIClient.EnterprisesApplicationsFunc = func(ctx context.Context, enterpriseName string, packageName string) (*androidmanagement.Application, error) {
|
|
for _, app := range androidApps {
|
|
if app.AdamID == packageName {
|
|
return &androidmanagement.Application{IconUrl: app.IconURL, Title: app.Name}, nil
|
|
}
|
|
}
|
|
return nil, ¬FoundError{}
|
|
}
|
|
|
|
// add Android app 1
|
|
var addAppResp addAppStoreAppResponse
|
|
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
|
|
AppStoreID: app1.AdamID,
|
|
Platform: fleet.AndroidPlatform,
|
|
}, http.StatusOK, &addAppResp)
|
|
app1TitleID := addAppResp.TitleID
|
|
|
|
// add Android app 2
|
|
addAppResp = addAppStoreAppResponse{}
|
|
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
|
|
AppStoreID: app2.AdamID,
|
|
Platform: fleet.AndroidPlatform,
|
|
}, http.StatusOK, &addAppResp)
|
|
app2TitleID := addAppResp.TitleID
|
|
|
|
require.NotEqual(t, app1TitleID, app2TitleID)
|
|
|
|
// add app 1 to Android setup experience
|
|
var putResp putSetupExperienceSoftwareResponse
|
|
s.DoJSON("PUT", "/api/latest/fleet/setup_experience/software", &putSetupExperienceSoftwareRequest{
|
|
Platform: string(fleet.AndroidPlatform),
|
|
TeamID: 0,
|
|
TitleIDs: []uint{app1TitleID},
|
|
}, http.StatusOK, &putResp)
|
|
|
|
// verify that the expected activity got created
|
|
s.lastActivityOfTypeMatches(fleet.ActivityEditedSetupExperienceSoftware{}.ActivityName(),
|
|
`{"platform": "android", "team_id": 0, "team_name": "", "fleet_id": 0, "fleet_name": ""}`, 0)
|
|
|
|
// list the available setup experience software and verify that only app 1 is installed at setup
|
|
var getResp getSetupExperienceSoftwareResponse
|
|
s.DoJSON("GET", "/api/latest/fleet/setup_experience/software", nil, http.StatusOK, &getResp,
|
|
"team_id", "0", "platform", string(fleet.AndroidPlatform), "order_key", "name")
|
|
require.Len(t, getResp.SoftwareTitles, 2)
|
|
require.Equal(t, app1TitleID, getResp.SoftwareTitles[0].ID)
|
|
require.Equal(t, app1.Name, getResp.SoftwareTitles[0].Name)
|
|
require.Equal(t, app1.AdamID, getResp.SoftwareTitles[0].AppStoreApp.AppStoreID)
|
|
require.NotNil(t, getResp.SoftwareTitles[0].AppStoreApp.InstallDuringSetup)
|
|
require.True(t, *getResp.SoftwareTitles[0].AppStoreApp.InstallDuringSetup)
|
|
require.Equal(t, app2TitleID, getResp.SoftwareTitles[1].ID)
|
|
require.Equal(t, app2.Name, getResp.SoftwareTitles[1].Name)
|
|
require.Equal(t, app2.AdamID, getResp.SoftwareTitles[1].AppStoreApp.AppStoreID)
|
|
require.NotNil(t, getResp.SoftwareTitles[1].AppStoreApp.InstallDuringSetup)
|
|
require.False(t, *getResp.SoftwareTitles[1].AppStoreApp.InstallDuringSetup)
|
|
|
|
// set app1 and app2 to be installed at setup
|
|
s.DoJSON("PUT", "/api/latest/fleet/setup_experience/software", &putSetupExperienceSoftwareRequest{
|
|
Platform: string(fleet.AndroidPlatform),
|
|
TeamID: 0,
|
|
TitleIDs: []uint{app1TitleID, app2TitleID},
|
|
}, http.StatusOK, &putResp)
|
|
|
|
getResp = getSetupExperienceSoftwareResponse{}
|
|
s.DoJSON("GET", "/api/latest/fleet/setup_experience/software", nil, http.StatusOK, &getResp,
|
|
"team_id", "0", "platform", string(fleet.AndroidPlatform), "order_key", "name")
|
|
require.Len(t, getResp.SoftwareTitles, 2)
|
|
require.Equal(t, app1TitleID, getResp.SoftwareTitles[0].ID)
|
|
require.NotNil(t, getResp.SoftwareTitles[0].AppStoreApp.InstallDuringSetup)
|
|
require.True(t, *getResp.SoftwareTitles[0].AppStoreApp.InstallDuringSetup)
|
|
require.Equal(t, app2TitleID, getResp.SoftwareTitles[1].ID)
|
|
require.NotNil(t, getResp.SoftwareTitles[1].AppStoreApp.InstallDuringSetup)
|
|
require.True(t, *getResp.SoftwareTitles[1].AppStoreApp.InstallDuringSetup)
|
|
|
|
// unset all apps to be installed at setup
|
|
s.DoJSON("PUT", "/api/latest/fleet/setup_experience/software", &putSetupExperienceSoftwareRequest{
|
|
Platform: string(fleet.AndroidPlatform),
|
|
TeamID: 0,
|
|
TitleIDs: []uint{},
|
|
}, http.StatusOK, &putResp)
|
|
|
|
getResp = getSetupExperienceSoftwareResponse{}
|
|
s.DoJSON("GET", "/api/latest/fleet/setup_experience/software", nil, http.StatusOK, &getResp,
|
|
"team_id", "0", "platform", string(fleet.AndroidPlatform), "order_key", "name")
|
|
require.Len(t, getResp.SoftwareTitles, 2)
|
|
require.Equal(t, app1TitleID, getResp.SoftwareTitles[0].ID)
|
|
require.NotNil(t, getResp.SoftwareTitles[0].AppStoreApp.InstallDuringSetup)
|
|
require.False(t, *getResp.SoftwareTitles[0].AppStoreApp.InstallDuringSetup)
|
|
require.Equal(t, app2TitleID, getResp.SoftwareTitles[1].ID)
|
|
require.NotNil(t, getResp.SoftwareTitles[1].AppStoreApp.InstallDuringSetup)
|
|
require.False(t, *getResp.SoftwareTitles[1].AppStoreApp.InstallDuringSetup)
|
|
}
|
|
|
|
func (s *integrationMDMTestSuite) enableAndroidMDM(t *testing.T) string {
|
|
appConf, err := s.ds.AppConfig(context.Background())
|
|
require.NoError(s.T(), err)
|
|
appConf.MDM.AndroidEnabledAndConfigured = false
|
|
err = s.ds.SaveAppConfig(context.Background(), appConf)
|
|
require.NoError(s.T(), err)
|
|
|
|
t.Cleanup(func() {
|
|
appConf, err := s.ds.AppConfig(context.Background())
|
|
require.NoError(s.T(), err)
|
|
appConf.MDM.AndroidEnabledAndConfigured = true
|
|
err = s.ds.SaveAppConfig(context.Background(), appConf)
|
|
require.NoError(s.T(), err)
|
|
})
|
|
|
|
enterpriseID := "LC02k5wxw7"
|
|
enterpriseSignupURL := "https://enterprise.google.com/signup/android/email?origin=android&thirdPartyToken=B4D779F1C4DD9A440"
|
|
s.androidAPIClient.InitCommonMocks()
|
|
|
|
s.androidAPIClient.EnterprisesCreateFunc = func(_ context.Context, _ androidmgmt.EnterprisesCreateRequest) (androidmgmt.EnterprisesCreateResponse, error) {
|
|
return androidmgmt.EnterprisesCreateResponse{
|
|
EnterpriseName: "enterprises/" + enterpriseID,
|
|
TopicName: "projects/android/topics/ae98ed130-5ce2-4ddb-a90a-191ec76976d5",
|
|
}, nil
|
|
}
|
|
|
|
s.androidAPIClient.EnterprisesPoliciesPatchFunc = func(_ context.Context, policyName string, _ *androidmanagement.Policy, opts androidmgmt.PoliciesPatchOpts) (*androidmanagement.Policy, error) {
|
|
assert.Contains(t, policyName, enterpriseID)
|
|
return &androidmanagement.Policy{}, nil
|
|
}
|
|
|
|
s.androidAPIClient.EnterpriseDeleteFunc = func(_ context.Context, enterpriseName string) error {
|
|
assert.Equal(t, "enterprises/"+enterpriseID, enterpriseName)
|
|
return nil
|
|
}
|
|
|
|
s.androidAPIClient.SignupURLsCreateFunc = func(_ context.Context, _, callbackURL string) (*android.SignupDetails, error) {
|
|
s.proxyCallbackURL = callbackURL
|
|
return &android.SignupDetails{
|
|
Url: enterpriseSignupURL,
|
|
Name: "signupUrls/Cb08124d0999c464f",
|
|
}, nil
|
|
}
|
|
|
|
s.androidAPIClient.EnterprisesPoliciesModifyPolicyApplicationsFunc = func(ctx context.Context, policyName string, appPolicies []*androidmanagement.ApplicationPolicy) (*androidmanagement.Policy, error) {
|
|
return &androidmanagement.Policy{}, nil
|
|
}
|
|
|
|
s.androidAPIClient.EnterprisesPoliciesRemovePolicyApplicationsFunc = func(ctx context.Context, policyName string, packageNames []string) (*androidmanagement.Policy, error) {
|
|
return &androidmanagement.Policy{}, nil
|
|
}
|
|
|
|
s.androidAPIClient.EnterprisesDevicesPatchFunc = func(ctx context.Context, deviceName string, device *androidmanagement.Device) (*androidmanagement.Device, error) {
|
|
return &androidmanagement.Device{}, nil
|
|
}
|
|
|
|
// Create enterprise
|
|
var signupResp android.EnterpriseSignupResponse
|
|
s.DoJSON("GET", "/api/v1/fleet/android_enterprise/signup_url", nil, http.StatusOK, &signupResp)
|
|
|
|
const enterpriseToken = "enterpriseToken"
|
|
|
|
// callback URL includes the host, need to extract the path so we can call it with our
|
|
// HTTP request helpers
|
|
u, err := url.Parse(s.proxyCallbackURL)
|
|
require.NoError(t, err)
|
|
s.Do("GET", u.Path, nil, http.StatusOK, "enterpriseToken", enterpriseToken)
|
|
|
|
// Update the LIST mock to return the enterprise after "creation"
|
|
s.androidAPIClient.EnterprisesListFunc = func(_ context.Context, _ string) ([]*androidmanagement.Enterprise, error) {
|
|
return []*androidmanagement.Enterprise{
|
|
{Name: "enterprises/" + enterpriseID},
|
|
}, nil
|
|
}
|
|
|
|
resp := android.GetEnterpriseResponse{}
|
|
s.DoJSON("GET", "/api/v1/fleet/android_enterprise", nil, http.StatusOK, &resp)
|
|
assert.Equal(t, enterpriseID, resp.EnterpriseID)
|
|
|
|
return enterpriseID
|
|
}
|
|
|
|
func (s *integrationMDMTestSuite) TestBatchAndroidApps() {
|
|
t := s.T()
|
|
ctx := context.Background()
|
|
|
|
appConf, err := s.ds.AppConfig(ctx)
|
|
require.NoError(s.T(), err)
|
|
appConf.MDM.AndroidEnabledAndConfigured = false
|
|
err = s.ds.SaveAppConfig(ctx, appConf)
|
|
require.NoError(s.T(), err)
|
|
|
|
s.enableAndroidMDM(t)
|
|
|
|
s.androidAPIClient.EnterprisesApplicationsFunc = func(ctx context.Context, enterpriseName string, packageName string) (*androidmanagement.Application, error) {
|
|
return &androidmanagement.Application{IconUrl: "https://example.com/1.jpg", Title: "Test App"}, nil
|
|
}
|
|
|
|
teamName := "Android Team For All Tests"
|
|
var createTeamResp teamResponse
|
|
s.DoJSON("POST", "/api/latest/fleet/teams", &fleet.Team{
|
|
Name: teamName,
|
|
}, http.StatusOK, &createTeamResp)
|
|
require.NotZero(t, createTeamResp.Team.ID)
|
|
teamID := &createTeamResp.Team.ID
|
|
|
|
t.Run("android app configurations", func(t *testing.T) {
|
|
// Android app with configuration
|
|
exampleConfiguration := json.RawMessage(`{"workProfileWidgets":"WORK_PROFILE_WIDGETS_ALLOWED"}`)
|
|
androidAppFoo := &fleet.VPPApp{
|
|
VPPAppTeam: fleet.VPPAppTeam{
|
|
AppTeamID: ptr.ValOrZero(teamID),
|
|
VPPAppID: fleet.VPPAppID{
|
|
AdamID: "com.foo",
|
|
Platform: fleet.AndroidPlatform,
|
|
},
|
|
Configuration: exampleConfiguration,
|
|
},
|
|
}
|
|
|
|
// Add Android app
|
|
var appWithConfigResp addAppStoreAppResponse
|
|
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps",
|
|
&addAppStoreAppRequest{
|
|
TeamID: teamID,
|
|
AppStoreID: androidAppFoo.AdamID,
|
|
Platform: androidAppFoo.VPPAppID.Platform,
|
|
Configuration: androidAppFoo.Configuration,
|
|
},
|
|
http.StatusOK, &appWithConfigResp,
|
|
)
|
|
|
|
// Verify that activity includes configuration
|
|
s.lastActivityMatches(fleet.ActivityAddedAppStoreApp{}.ActivityName(),
|
|
fmt.Sprintf(`{"team_name": "%s", "fleet_name": "%s", "software_title": "%s", "software_title_id": %d, "app_store_id": "%s", "team_id": %d, "fleet_id": %d, "platform": "%s", "self_service": true,"configuration": %s}`,
|
|
teamName, teamName, "Test App", appWithConfigResp.TitleID, androidAppFoo.AdamID, ptr.ValOrZero(teamID), ptr.ValOrZero(teamID), androidAppFoo.Platform, androidAppFoo.Configuration), 0)
|
|
|
|
var listSWTitles listSoftwareTitlesResponse
|
|
s.DoJSON("GET", "/api/latest/fleet/software/titles", nil, http.StatusOK, &listSWTitles, "team_id", fmt.Sprint(*teamID))
|
|
s.Assert().Len(listSWTitles.SoftwareTitles, 1)
|
|
s.Assert().Equal(androidAppFoo.AdamID, listSWTitles.SoftwareTitles[0].AppStoreApp.AppStoreID)
|
|
|
|
// Batch app store apps call won't create an activity
|
|
var batchResp batchAssociateAppStoreAppsResponse
|
|
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps/batch",
|
|
batchAssociateAppStoreAppsRequest{
|
|
DryRun: false,
|
|
Apps: []fleet.VPPBatchPayload{
|
|
{AppStoreID: "app_1", SelfService: true, Platform: fleet.AndroidPlatform, Configuration: json.RawMessage("{}")},
|
|
{AppStoreID: "app_2", SelfService: true, Platform: fleet.AndroidPlatform, Configuration: json.RawMessage("{}")},
|
|
{AppStoreID: "app_3", SelfService: true, Platform: fleet.AndroidPlatform, Configuration: json.RawMessage("{}")},
|
|
{AppStoreID: "app_4", SelfService: true, Platform: fleet.AndroidPlatform, Configuration: exampleConfiguration},
|
|
},
|
|
},
|
|
http.StatusOK, &batchResp, "team_name", teamName,
|
|
)
|
|
|
|
s.DoJSON("GET", "/api/latest/fleet/software/titles", nil, http.StatusOK, &listSWTitles, "team_id", fmt.Sprint(*teamID))
|
|
s.Assert().Len(listSWTitles.SoftwareTitles, 4)
|
|
// Look up title IDs by app store ID (ordering within tied hosts_count is by ID, not name).
|
|
var titleApp1, titleApp2 uint
|
|
for _, st := range listSWTitles.SoftwareTitles {
|
|
if st.AppStoreApp != nil {
|
|
switch st.AppStoreApp.AppStoreID {
|
|
case "app_1":
|
|
titleApp1 = st.ID
|
|
case "app_2":
|
|
titleApp2 = st.ID
|
|
}
|
|
}
|
|
}
|
|
require.NotZero(t, titleApp1)
|
|
require.NotZero(t, titleApp2)
|
|
|
|
// Batch app store apps call won't create an activity
|
|
|
|
// Add apps to team 0
|
|
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps/batch",
|
|
batchAssociateAppStoreAppsRequest{
|
|
DryRun: false,
|
|
Apps: []fleet.VPPBatchPayload{
|
|
{AppStoreID: "app_1", SelfService: true, Platform: fleet.AndroidPlatform, Configuration: json.RawMessage("{}")},
|
|
{AppStoreID: "app_2", SelfService: true, Platform: fleet.AndroidPlatform, Configuration: json.RawMessage("{}")},
|
|
{AppStoreID: "app_3", SelfService: true, Platform: fleet.AndroidPlatform, Configuration: json.RawMessage("{}")},
|
|
{AppStoreID: "app_4", SelfService: true, Platform: fleet.AndroidPlatform, Configuration: json.RawMessage("{}")},
|
|
},
|
|
}, http.StatusOK, &batchResp,
|
|
)
|
|
|
|
s.DoJSON("GET", "/api/latest/fleet/software/titles", nil, http.StatusOK, &listSWTitles, "team_id", fmt.Sprint(0))
|
|
s.Assert().Len(listSWTitles.SoftwareTitles, 4)
|
|
|
|
// Update configurations
|
|
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps/batch",
|
|
batchAssociateAppStoreAppsRequest{
|
|
DryRun: false,
|
|
Apps: []fleet.VPPBatchPayload{
|
|
{AppStoreID: "app_1", Platform: fleet.AndroidPlatform, Configuration: nil},
|
|
{AppStoreID: "app_2", Platform: fleet.AndroidPlatform, Configuration: exampleConfiguration},
|
|
{AppStoreID: "app_3", Platform: fleet.AndroidPlatform, Configuration: json.RawMessage("{}")},
|
|
{AppStoreID: "app_4", Platform: fleet.AndroidPlatform, Configuration: json.RawMessage("{}")},
|
|
},
|
|
},
|
|
http.StatusOK, &batchResp, "team_name", teamName,
|
|
)
|
|
|
|
s.DoJSON("GET", "/api/latest/fleet/software/titles", nil, http.StatusOK, &listSWTitles, "team_id", fmt.Sprint(*teamID))
|
|
s.Assert().Len(listSWTitles.SoftwareTitles, 4)
|
|
|
|
var titleResp getSoftwareTitleResponse
|
|
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleApp1), &getSoftwareTitleRequest{
|
|
ID: titleApp1,
|
|
TeamID: teamID,
|
|
}, http.StatusOK, &titleResp)
|
|
require.Equal(t, "app_1", *titleResp.SoftwareTitle.ApplicationID)
|
|
require.Equal(t, json.RawMessage(`{}`), titleResp.SoftwareTitle.AppStoreApp.Configuration)
|
|
|
|
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleApp2), &getSoftwareTitleRequest{
|
|
ID: titleApp2,
|
|
TeamID: teamID,
|
|
}, http.StatusOK, &titleResp)
|
|
require.Equal(t, "app_2", *titleResp.SoftwareTitle.ApplicationID)
|
|
require.Contains(t, string(titleResp.SoftwareTitle.AppStoreApp.Configuration), `"workProfileWidgets": "WORK_PROFILE_WIDGETS_ALLOWED"`)
|
|
|
|
// Remove 2 other apps, 2 configurations should be deleted and 2 should be emptied/remain
|
|
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps/batch",
|
|
batchAssociateAppStoreAppsRequest{
|
|
DryRun: false,
|
|
Apps: []fleet.VPPBatchPayload{
|
|
{AppStoreID: "app_1", Platform: fleet.AndroidPlatform, Configuration: nil},
|
|
{AppStoreID: "app_2", Platform: fleet.AndroidPlatform, Configuration: exampleConfiguration},
|
|
},
|
|
},
|
|
http.StatusOK, &batchResp, "team_name", teamName,
|
|
)
|
|
|
|
s.DoJSON("GET", "/api/latest/fleet/software/titles", nil, http.StatusOK, &listSWTitles, "team_id", fmt.Sprint(*teamID))
|
|
s.Assert().Len(listSWTitles.SoftwareTitles, 2)
|
|
|
|
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleApp1), &getSoftwareTitleRequest{
|
|
ID: titleApp1,
|
|
TeamID: teamID,
|
|
}, http.StatusOK, &titleResp)
|
|
require.Equal(t, "app_1", *titleResp.SoftwareTitle.ApplicationID)
|
|
require.Equal(t, json.RawMessage(`{}`), titleResp.SoftwareTitle.AppStoreApp.Configuration)
|
|
|
|
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleApp2), &getSoftwareTitleRequest{
|
|
ID: titleApp2,
|
|
TeamID: teamID,
|
|
}, http.StatusOK, &titleResp)
|
|
require.Equal(t, "app_2", *titleResp.SoftwareTitle.ApplicationID)
|
|
require.Contains(t, string(titleResp.SoftwareTitle.AppStoreApp.Configuration), `"workProfileWidgets": "WORK_PROFILE_WIDGETS_ALLOWED"`)
|
|
|
|
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps/batch",
|
|
batchAssociateAppStoreAppsRequest{
|
|
DryRun: false,
|
|
Apps: []fleet.VPPBatchPayload{},
|
|
},
|
|
http.StatusOK, &batchResp, "team_name", teamName,
|
|
)
|
|
})
|
|
t.Run("android app setup experience", func(t *testing.T) {
|
|
// Get initial count of edited setup experience activities
|
|
var initialCount int
|
|
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
|
err := sqlx.GetContext(ctx, q, &initialCount, `SELECT COUNT(id) FROM activities WHERE activity_type = 'edited_setup_experience_software'`)
|
|
require.NoError(t, err)
|
|
return nil
|
|
})
|
|
|
|
var batchResp batchAssociateAppStoreAppsResponse
|
|
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps/batch",
|
|
batchAssociateAppStoreAppsRequest{
|
|
DryRun: false,
|
|
Apps: []fleet.VPPBatchPayload{},
|
|
},
|
|
http.StatusOK, &batchResp,
|
|
)
|
|
// Should create an edited setup experience activity, as new software was added
|
|
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps/batch",
|
|
batchAssociateAppStoreAppsRequest{
|
|
DryRun: false,
|
|
Apps: []fleet.VPPBatchPayload{
|
|
{AppStoreID: "app_1", Platform: fleet.AndroidPlatform, InstallDuringSetup: ptr.Bool(false)},
|
|
{AppStoreID: "app_2", Platform: fleet.AndroidPlatform, InstallDuringSetup: ptr.Bool(true)},
|
|
},
|
|
},
|
|
http.StatusOK, &batchResp, "team_name", teamName,
|
|
)
|
|
|
|
// Should not create an edited setup experience activity, nothing changed
|
|
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps/batch",
|
|
batchAssociateAppStoreAppsRequest{
|
|
DryRun: false,
|
|
Apps: []fleet.VPPBatchPayload{
|
|
{AppStoreID: "app_1", Platform: fleet.AndroidPlatform, InstallDuringSetup: ptr.Bool(false)},
|
|
{AppStoreID: "app_2", Platform: fleet.AndroidPlatform, InstallDuringSetup: ptr.Bool(true)},
|
|
},
|
|
},
|
|
http.StatusOK, &batchResp, "team_name", teamName,
|
|
)
|
|
|
|
// Should create an edited setup experience activity, existing software changed
|
|
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps/batch",
|
|
batchAssociateAppStoreAppsRequest{
|
|
DryRun: false,
|
|
Apps: []fleet.VPPBatchPayload{
|
|
{AppStoreID: "app_1", Platform: fleet.AndroidPlatform, InstallDuringSetup: ptr.Bool(true)},
|
|
{AppStoreID: "app_2", Platform: fleet.AndroidPlatform, InstallDuringSetup: ptr.Bool(true)},
|
|
},
|
|
},
|
|
http.StatusOK, &batchResp, "team_name", teamName,
|
|
)
|
|
|
|
// Should create an edited setup experience activity, as new software was added
|
|
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps/batch",
|
|
batchAssociateAppStoreAppsRequest{
|
|
DryRun: false,
|
|
Apps: []fleet.VPPBatchPayload{
|
|
{AppStoreID: "app_1", Platform: fleet.AndroidPlatform, InstallDuringSetup: ptr.Bool(true)},
|
|
{AppStoreID: "app_2", Platform: fleet.AndroidPlatform, InstallDuringSetup: ptr.Bool(true)},
|
|
{AppStoreID: "app_3", Platform: fleet.AndroidPlatform, InstallDuringSetup: ptr.Bool(true)},
|
|
},
|
|
},
|
|
http.StatusOK, &batchResp, "team_name", teamName,
|
|
)
|
|
var count int
|
|
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
|
err := sqlx.GetContext(ctx, q, &count, `SELECT COUNT(id) FROM activities WHERE activity_type = 'edited_setup_experience_software'`)
|
|
require.NoError(t, err)
|
|
return nil
|
|
})
|
|
require.Equal(t, initialCount+3, count)
|
|
})
|
|
}
|
|
|
|
func (s *integrationMDMTestSuite) TestAndroidAppsUninstallOnDelete() {
|
|
ctx := context.Background()
|
|
t := s.T()
|
|
|
|
s.setSkipWorkerJobs(t)
|
|
s.setVPPTokenForTeam(0)
|
|
appConf, err := s.ds.AppConfig(ctx)
|
|
require.NoError(t, err)
|
|
appConf.MDM.AndroidEnabledAndConfigured = false
|
|
err = s.ds.SaveAppConfig(ctx, appConf)
|
|
require.NoError(t, err)
|
|
|
|
// create a team for the test host that will not be affected
|
|
var createTeamResp teamResponse
|
|
s.DoJSON("POST", "/api/latest/fleet/teams", &fleet.Team{Name: "test"}, http.StatusOK, &createTeamResp)
|
|
teamID := createTeamResp.Team.ID
|
|
|
|
s.enableAndroidMDM(t)
|
|
|
|
amapiConfig := struct {
|
|
AppIDsToNames map[string]string
|
|
EnterprisesPoliciesPatchValidator func(policyName string, policy *androidmanagement.Policy, opts androidmgmt.PoliciesPatchOpts)
|
|
}{
|
|
AppIDsToNames: map[string]string{},
|
|
EnterprisesPoliciesPatchValidator: func(policyName string, policy *androidmanagement.Policy, opts androidmgmt.PoliciesPatchOpts) {},
|
|
}
|
|
|
|
s.androidAPIClient.EnterprisesApplicationsFunc = func(ctx context.Context, enterpriseName string, packageName string) (*androidmanagement.Application, error) {
|
|
title := amapiConfig.AppIDsToNames[packageName]
|
|
return &androidmanagement.Application{IconUrl: "https://example.com/1.jpg", Title: title}, nil
|
|
}
|
|
|
|
s.androidAPIClient.EnterprisesPoliciesPatchFunc = func(ctx context.Context, policyName string, policy *androidmanagement.Policy, opts androidmgmt.PoliciesPatchOpts) (*androidmanagement.Policy, error) {
|
|
amapiConfig.EnterprisesPoliciesPatchValidator(policyName, policy, opts)
|
|
return &androidmanagement.Policy{}, nil
|
|
}
|
|
|
|
// add some Android apps
|
|
androidApps := make([]*fleet.VPPApp, 5)
|
|
titleIDs := make([]uint, len(androidApps))
|
|
for i := range androidApps {
|
|
androidApps[i] = &fleet.VPPApp{
|
|
VPPAppTeam: fleet.VPPAppTeam{
|
|
VPPAppID: fleet.VPPAppID{AdamID: "com.app" + fmt.Sprint(i), Platform: fleet.AndroidPlatform},
|
|
},
|
|
Name: "App" + fmt.Sprint(i),
|
|
BundleIdentifier: "com.app" + fmt.Sprint(i),
|
|
IconURL: "https://example.com/images/" + fmt.Sprint(i),
|
|
}
|
|
amapiConfig.AppIDsToNames[androidApps[i].AdamID] = androidApps[i].Name
|
|
|
|
var addAppResp addAppStoreAppResponse
|
|
|
|
// last app goes on the team, will not affect the test
|
|
var teamIDPtr *uint
|
|
if i == len(androidApps)-1 {
|
|
teamIDPtr = &teamID
|
|
}
|
|
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
|
|
AppStoreID: androidApps[i].AdamID,
|
|
Platform: fleet.AndroidPlatform,
|
|
TeamID: teamIDPtr,
|
|
}, http.StatusOK, &addAppResp)
|
|
titleIDs[i] = addAppResp.TitleID
|
|
}
|
|
|
|
// delete app [0], does not affect any host so no removeApplicationsPolicy call
|
|
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleIDs[0]), nil, http.StatusNoContent, "team_id", "0")
|
|
s.runWorkerUntilDone()
|
|
require.False(t, s.androidAPIClient.EnterprisesPoliciesRemovePolicyApplicationsFuncInvoked)
|
|
require.False(t, s.androidAPIClient.EnterprisesPoliciesModifyPolicyApplicationsFuncInvoked)
|
|
|
|
// enroll a few Android devices
|
|
secrets, err := s.ds.GetEnrollSecrets(ctx, nil)
|
|
require.NoError(t, err)
|
|
require.Len(t, secrets, 1)
|
|
|
|
assets, err := s.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetAndroidPubSubToken}, nil)
|
|
require.NoError(t, err)
|
|
pubsubToken := assets[fleet.MDMAssetAndroidPubSubToken]
|
|
require.NotEmpty(t, pubsubToken.Value)
|
|
|
|
deviceID1 := createAndroidDeviceID("test-android")
|
|
deviceID2 := createAndroidDeviceID("test-android-2")
|
|
deviceID3 := createAndroidDeviceID("test-android-3")
|
|
|
|
enterpriseSpecificID1 := strings.ToUpper(uuid.New().String())
|
|
enterpriseSpecificID2 := strings.ToUpper(uuid.New().String())
|
|
enterpriseSpecificID3 := strings.ToUpper(uuid.New().String())
|
|
var req android_service.PubSubPushRequest
|
|
for _, d := range []struct {
|
|
id string
|
|
esi string
|
|
}{{deviceID1, enterpriseSpecificID1}, {deviceID2, enterpriseSpecificID2}, {deviceID3, enterpriseSpecificID3}} {
|
|
enrollmentMessage := enrollmentMessageWithEnterpriseSpecificID(t, androidmanagement.Device{
|
|
Name: d.id,
|
|
EnrollmentTokenData: fmt.Sprintf(`{"EnrollSecret": "%s"}`, secrets[0].Secret),
|
|
}, d.esi)
|
|
req = android_service.PubSubPushRequest{PubSubMessage: *enrollmentMessage}
|
|
s.Do("POST", "/api/v1/fleet/android_enterprise/pubsub", &req, http.StatusOK, "token", string(pubsubToken.Value))
|
|
}
|
|
|
|
var hosts listHostsResponse
|
|
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &hosts)
|
|
require.Len(t, hosts.Hosts, 3)
|
|
host1 := hosts.Hosts[0]
|
|
host2 := hosts.Hosts[1]
|
|
host3 := hosts.Hosts[2] // isolated host, not affected by test
|
|
|
|
// transfer host3 to the team
|
|
s.DoJSON("POST", "/api/latest/fleet/hosts/transfer", addHostsToTeamRequest{
|
|
TeamID: &teamID,
|
|
HostIDs: []uint{host3.ID},
|
|
}, http.StatusOK, &addHostsToTeamResponse{})
|
|
|
|
// run the worker, the hosts get the 3 remaining android apps as self-service-available
|
|
s.runWorkerUntilDone()
|
|
require.False(t, s.androidAPIClient.EnterprisesPoliciesRemovePolicyApplicationsFuncInvoked)
|
|
require.True(t, s.androidAPIClient.EnterprisesPoliciesModifyPolicyApplicationsFuncInvoked)
|
|
s.androidAPIClient.EnterprisesPoliciesModifyPolicyApplicationsFuncInvoked = false
|
|
s.androidAPIClient.EnterprisesPoliciesPatchFuncInvoked = false // from now on (after device enrollment), this gets called only when we expect it to
|
|
|
|
for _, host := range []fleet.HostResponse{host1, host2} {
|
|
var getHostSw getHostSoftwareResponse
|
|
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw,
|
|
"available_for_install", "true", "order_key", "name")
|
|
require.Len(t, getHostSw.Software, 3)
|
|
require.NotNil(t, getHostSw.Software[0].AppStoreApp)
|
|
require.Equal(t, androidApps[1].AdamID, getHostSw.Software[0].AppStoreApp.AppStoreID)
|
|
require.NotNil(t, getHostSw.Software[1].AppStoreApp)
|
|
require.Equal(t, androidApps[2].AdamID, getHostSw.Software[1].AppStoreApp.AppStoreID)
|
|
require.NotNil(t, getHostSw.Software[2].AppStoreApp)
|
|
require.Equal(t, androidApps[3].AdamID, getHostSw.Software[2].AppStoreApp.AppStoreID)
|
|
}
|
|
|
|
// delete app [1], should trigger remove from both hosts
|
|
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleIDs[1]), nil, http.StatusNoContent, "team_id", "0")
|
|
s.runWorkerUntilDone()
|
|
require.True(t, s.androidAPIClient.EnterprisesPoliciesRemovePolicyApplicationsFuncInvoked)
|
|
require.False(t, s.androidAPIClient.EnterprisesPoliciesModifyPolicyApplicationsFuncInvoked)
|
|
require.False(t, s.androidAPIClient.EnterprisesPoliciesPatchFuncInvoked)
|
|
s.androidAPIClient.EnterprisesPoliciesRemovePolicyApplicationsFuncInvoked = false
|
|
|
|
for _, host := range []fleet.HostResponse{host1, host2} {
|
|
var getHostSw getHostSoftwareResponse
|
|
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw,
|
|
"available_for_install", "true", "order_key", "name")
|
|
require.Len(t, getHostSw.Software, 2)
|
|
require.NotNil(t, getHostSw.Software[0].AppStoreApp)
|
|
require.Equal(t, androidApps[2].AdamID, getHostSw.Software[0].AppStoreApp.AppStoreID)
|
|
require.NotNil(t, getHostSw.Software[1].AppStoreApp)
|
|
require.Equal(t, androidApps[3].AdamID, getHostSw.Software[1].AppStoreApp.AppStoreID)
|
|
}
|
|
|
|
// batch-set to keep only app [3] (effectively deletes app[2]), as per a gitops run
|
|
var batchResp batchAssociateAppStoreAppsResponse
|
|
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps/batch", batchAssociateAppStoreAppsRequest{
|
|
Apps: []fleet.VPPBatchPayload{
|
|
{AppStoreID: androidApps[3].AdamID, SelfService: true, Platform: fleet.AndroidPlatform, Configuration: json.RawMessage("{}")},
|
|
},
|
|
}, http.StatusOK, &batchResp)
|
|
|
|
s.runWorkerUntilDone() // calls policies patch, which sets (replaces) the list of apps
|
|
require.False(t, s.androidAPIClient.EnterprisesPoliciesRemovePolicyApplicationsFuncInvoked)
|
|
require.False(t, s.androidAPIClient.EnterprisesPoliciesModifyPolicyApplicationsFuncInvoked)
|
|
require.True(t, s.androidAPIClient.EnterprisesPoliciesPatchFuncInvoked)
|
|
s.androidAPIClient.EnterprisesPoliciesPatchFuncInvoked = false
|
|
|
|
for _, host := range []fleet.HostResponse{host1, host2} {
|
|
var getHostSw getHostSoftwareResponse
|
|
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw,
|
|
"available_for_install", "true", "order_key", "name")
|
|
require.Len(t, getHostSw.Software, 1)
|
|
require.NotNil(t, getHostSw.Software[0].AppStoreApp)
|
|
require.Equal(t, androidApps[3].AdamID, getHostSw.Software[0].AppStoreApp.AppStoreID)
|
|
}
|
|
|
|
// batch-set to remove all apps
|
|
batchResp = batchAssociateAppStoreAppsResponse{}
|
|
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps/batch", batchAssociateAppStoreAppsRequest{
|
|
Apps: []fleet.VPPBatchPayload{},
|
|
}, http.StatusOK, &batchResp)
|
|
|
|
s.runWorkerUntilDone() // calls policies patch, which sets (replaces) the list of apps
|
|
require.False(t, s.androidAPIClient.EnterprisesPoliciesRemovePolicyApplicationsFuncInvoked)
|
|
require.False(t, s.androidAPIClient.EnterprisesPoliciesModifyPolicyApplicationsFuncInvoked)
|
|
require.True(t, s.androidAPIClient.EnterprisesPoliciesPatchFuncInvoked)
|
|
s.androidAPIClient.EnterprisesPoliciesPatchFuncInvoked = false
|
|
|
|
for _, host := range []fleet.HostResponse{host1, host2} {
|
|
var getHostSw getHostSoftwareResponse
|
|
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw,
|
|
"available_for_install", "true", "order_key", "name")
|
|
require.Len(t, getHostSw.Software, 0)
|
|
}
|
|
|
|
// batch-set again without any app is a no-op
|
|
batchResp = batchAssociateAppStoreAppsResponse{}
|
|
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps/batch", batchAssociateAppStoreAppsRequest{
|
|
Apps: []fleet.VPPBatchPayload{},
|
|
}, http.StatusOK, &batchResp)
|
|
|
|
s.runWorkerUntilDone()
|
|
require.False(t, s.androidAPIClient.EnterprisesPoliciesRemovePolicyApplicationsFuncInvoked)
|
|
require.False(t, s.androidAPIClient.EnterprisesPoliciesModifyPolicyApplicationsFuncInvoked)
|
|
require.False(t, s.androidAPIClient.EnterprisesPoliciesPatchFuncInvoked)
|
|
|
|
// isolated host was unaffected, still has the same team app
|
|
var getHostSw getHostSoftwareResponse
|
|
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host3.ID), nil, http.StatusOK, &getHostSw,
|
|
"available_for_install", "true", "order_key", "name")
|
|
require.Len(t, getHostSw.Software, 1)
|
|
require.NotNil(t, getHostSw.Software[0].AppStoreApp)
|
|
require.Equal(t, androidApps[4].AdamID, getHostSw.Software[0].AppStoreApp.AppStoreID)
|
|
}
|