diff --git a/cmd/fleetctl/fleetctl/generate_gitops.go b/cmd/fleetctl/fleetctl/generate_gitops.go index 3a0ec07fdd..3994801cdc 100644 --- a/cmd/fleetctl/fleetctl/generate_gitops.go +++ b/cmd/fleetctl/fleetctl/generate_gitops.go @@ -2,6 +2,7 @@ package fleetctl import ( "bytes" + "encoding/json" "errors" "fmt" "math" @@ -1596,7 +1597,7 @@ func (cmd *GenerateGitopsCommand) generateSoftware(filePath string, teamID uint, if downloadIcons && softwareTitle.IconUrl != nil && strings.HasPrefix(*softwareTitle.IconUrl, "/api") { fileName := fmt.Sprintf("lib/%s/icons/%s", teamFilename, filenamePrefix+"-icon.png") path := fmt.Sprintf("../%s", fileName) - softwareSpec["icon"] = map[string]interface{}{ + softwareSpec["icon"] = map[string]any{ "path": path, } icon, err := cmd.Client.GetSoftwareTitleIcon(softwareTitle.ID, teamID) @@ -1608,6 +1609,25 @@ func (cmd *GenerateGitopsCommand) generateSoftware(filePath string, teamID uint, // TODO write files immediately rather than queueing them up cmd.FilesToWrite[fileName] = icon } + + config := softwareTitle.AppStoreApp.Configuration + if config != nil && !slices.Equal(config, json.RawMessage("{}")) { + fileName := fmt.Sprintf("lib/%s/configs/%s", teamFilename, filenamePrefix+"-config.json") + path := fmt.Sprintf("../%s", fileName) + softwareSpec["configuration"] = map[string]any{ + "path": path, + } + + // format config because it is received with incorrect indentation + var buf bytes.Buffer + err := json.Indent(&buf, softwareTitle.AppStoreApp.Configuration, "", " ") + if err != nil { + fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error formatting android app config %s: %s\n", sw.Name, err) + return nil, err + } + + cmd.FilesToWrite[fileName] = buf.Bytes() + } } var labels []fleet.SoftwareScopeLabel diff --git a/ee/server/service/vpp.go b/ee/server/service/vpp.go index 29d2e212c7..edc1c3014c 100644 --- a/ee/server/service/vpp.go +++ b/ee/server/service/vpp.go @@ -121,6 +121,7 @@ func (svc *Service) BatchAssociateVPPApps(ctx context.Context, teamName string, LabelsIncludeAny: payload.LabelsIncludeAny, Categories: payload.Categories, DisplayName: payload.DisplayName, + Configuration: payload.Configuration, }) } @@ -178,6 +179,7 @@ func (svc *Service) BatchAssociateVPPApps(ctx context.Context, teamName string, switch payload.Platform { case fleet.AndroidPlatform: appStoreApp.SelfService = true + appStoreApp.Configuration = payload.Configuration incomingAndroidApps = append(incomingAndroidApps, appStoreApp) case fleet.IOSPlatform, fleet.IPadOSPlatform, fleet.MacOSPlatform: incomingAppleApps = append(incomingAppleApps, appStoreApp) diff --git a/server/datastore/mysql/android.go b/server/datastore/mysql/android.go index c22facbc21..071a790d4e 100644 --- a/server/datastore/mysql/android.go +++ b/server/datastore/mysql/android.go @@ -12,6 +12,7 @@ import ( "github.com/fleetdm/fleet/v4/server/datastore/mysql/common_mysql" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mdm/android" + "github.com/fleetdm/fleet/v4/server/ptr" "github.com/go-kit/log/level" "github.com/google/uuid" "github.com/jmoiron/sqlx" @@ -1645,7 +1646,7 @@ WHERE } // HasAndroidAppConfigurationChanged checks if the new configuration for an Android app -// identified by adam_id and global_or_team_id is different from the existing one. This +// identified by application_id and global_or_team_id is different from the existing one. This // is a datastore method so that we rely on mysql's canonicalisation of JSON for comparison. func (ds *Datastore) HasAndroidAppConfigurationChanged(ctx context.Context, applicationID string, globalOrTeamID uint, newConfig json.RawMessage) (bool, error) { const stmt = ` @@ -1834,16 +1835,6 @@ func (ds *Datastore) updateAndroidAppConfigurationTx(ctx context.Context, tx sql return ctxerr.Wrap(ctx, err, "validating android app configuration") } - var tid *uint - var globalOrTeamID uint - if teamID != nil { - globalOrTeamID = *teamID - - if *teamID > 0 { - tid = teamID - } - } - stmt := ` INSERT INTO android_app_configurations (application_id, team_id, global_or_team_id, configuration) @@ -1852,7 +1843,7 @@ func (ds *Datastore) updateAndroidAppConfigurationTx(ctx context.Context, tx sql configuration = VALUES(configuration) ` - _, err = tx.ExecContext(ctx, stmt, appID, tid, globalOrTeamID, config) + _, err = tx.ExecContext(ctx, stmt, appID, teamID, ptr.ValOrZero(teamID), config) if err != nil { return ctxerr.Wrap(ctx, err, "updateAndroidAppConfiguration") } diff --git a/server/datastore/mysql/vpp.go b/server/datastore/mysql/vpp.go index b8ee3465d5..b94d592a9b 100644 --- a/server/datastore/mysql/vpp.go +++ b/server/datastore/mysql/vpp.go @@ -433,7 +433,7 @@ func (ds *Datastore) SetTeamVPPApps(ctx context.Context, teamID *uint, incomingA // upsert it if it does not exist or labels or SelfService or InstallDuringSetup flags are changed existingApp, isExistingApp := existingApps[incomingApp.VPPAppID] incomingApp.AppTeamID = existingApp.AppTeamID - var labelsChanged, categoriesChanged, displayNameChanged bool + var labelsChanged, categoriesChanged, displayNameChanged, configurationChanged bool if isExistingApp { existingLabels, err := ds.getExistingLabels(ctx, incomingApp.AppTeamID) if err != nil { @@ -453,6 +453,17 @@ func (ds *Datastore) SetTeamVPPApps(ctx context.Context, teamID *uint, incomingA incomingDisplayName := ptr.ValOrZero(incomingApp.DisplayName) displayNameChanged = existingDisplayName != incomingDisplayName + if incomingApp.Platform == fleet.AndroidPlatform { + configurationChanged, err = ds.HasAndroidAppConfigurationChanged(ctx, existingApp.AdamID, ptr.ValOrZero(teamID), incomingApp.Configuration) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting existing configuration for android app") + } + // Set configuration to empty if it exists and is provided as null + if configurationChanged && len(incomingApp.Configuration) == 0 { + incomingApp.Configuration = json.RawMessage("{}") + } + } + } // Get the hosts that are NOT in label scope currently (before the update happens) @@ -470,6 +481,7 @@ func (ds *Datastore) SetTeamVPPApps(ctx context.Context, teamID *uint, incomingA labelsChanged || categoriesChanged || displayNameChanged || + configurationChanged || incomingApp.InstallDuringSetup != nil && existingApp.InstallDuringSetup != nil && *incomingApp.InstallDuringSetup != *existingApp.InstallDuringSetup { @@ -483,7 +495,7 @@ func (ds *Datastore) SetTeamVPPApps(ctx context.Context, teamID *uint, incomingA if teamID != nil && *teamID > 0 { tm, err := ds.TeamLite(ctx, *teamID) if err != nil { - return ctxerr.Wrap(ctx, err, "get team for VPP app conflict error") + return ctxerr.Wrap(ctx, err, "get team name for VPP app conflict error") } teamName = tm.Name } @@ -541,6 +553,12 @@ func (ds *Datastore) SetTeamVPPApps(ctx context.Context, teamID *uint, incomingA } } + if toAdd.Configuration != nil { + if err := ds.updateAndroidAppConfigurationTx(ctx, tx, teamID, toAdd.AdamID, toAdd.Configuration); err != nil { + return ctxerr.Wrap(ctx, err, "setting configuration for android app") + } + } + if hostsNotInScope, ok := appsWithChangedLabels[toAdd.AppTeamID]; ok { hostsInScope, err := ds.GetIncludedHostIDMapForVPPAppTx(ctx, tx, toAdd.AppTeamID) if err != nil { @@ -827,6 +845,11 @@ func removeVPPAppTeams(ctx context.Context, tx sqlx.ExtContext, appID fleet.VPPA return ctxerr.Wrap(ctx, err, "deleting vpp app from team") } + _, err = tx.ExecContext(ctx, `DELETE FROM android_app_configurations WHERE application_id = ? AND global_or_team_id = ?`, appID.AdamID, tmID) + if err != nil { + return ctxerr.Wrap(ctx, err, "deleting android app configuration") + } + return nil } diff --git a/server/datastore/mysql/vpp_test.go b/server/datastore/mysql/vpp_test.go index c062770cbb..8fd4c75219 100644 --- a/server/datastore/mysql/vpp_test.go +++ b/server/datastore/mysql/vpp_test.go @@ -2,6 +2,7 @@ package mysql import ( "context" + "encoding/json" "fmt" "testing" "time" @@ -41,6 +42,7 @@ func TestVPP(t *testing.T) { {"TestGetUnverifiedVPPInstallsForHost", testGetUnverifiedVPPInstallsForHost}, {"SoftwareTitleDisplayName", testSoftwareTitleDisplayNameVPP}, {"AndroidVPPAppStatus", testAndroidVPPAppStatus}, + {"AndroidAppConfigs", testAndroidAppConfigs}, } for _, c := range cases { @@ -2424,3 +2426,84 @@ func testAndroidVPPAppStatus(t *testing.T, ds *Datastore) { require.Len(t, hosts, 1) require.Equal(t, host3.Host.ID, hosts[0].ID) } + +func testAndroidAppConfigs(t *testing.T, ds *Datastore) { + ctx := context.Background() + + // Create a team + team, err := ds.NewTeam(ctx, &fleet.Team{Name: "androids"}) + require.NoError(t, err) + + // Insert some VPP apps for no team + app1 := &fleet.VPPApp{Name: "android_app_1", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "1", Platform: fleet.AndroidPlatform}}, BundleIdentifier: "b1"} + _, err = ds.InsertVPPAppWithTeam(ctx, app1, nil) + require.NoError(t, err) + app2 := &fleet.VPPApp{Name: "android_app_2", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "2", Platform: fleet.AndroidPlatform}}, BundleIdentifier: "b2"} + _, err = ds.InsertVPPAppWithTeam(ctx, app2, nil) + require.NoError(t, err) + app3 := &fleet.VPPApp{Name: "android_app_3", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "3", Platform: fleet.AndroidPlatform}}, BundleIdentifier: "b3"} + _, err = ds.InsertVPPAppWithTeam(ctx, app3, nil) + require.NoError(t, err) + app4 := &fleet.VPPApp{Name: "android_app_4", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "4", Platform: fleet.AndroidPlatform}}, BundleIdentifier: "b4"} + _, err = ds.InsertVPPAppWithTeam(ctx, app4, nil) + require.NoError(t, err) + + config1 := json.RawMessage(`{"workProfileWidgets":"WORK_PROFILE_WIDGETS_ALLOWED", "managedConfiguration": {"1":1}}`) + expectedConfig1 := json.RawMessage(`{"workProfileWidgets": "WORK_PROFILE_WIDGETS_ALLOWED", "managedConfiguration": {"1": 1}}`) + + err = ds.SetTeamVPPApps(ctx, &team.ID, []fleet.VPPAppTeam{ + {VPPAppID: app1.VPPAppID, SelfService: true, DisplayName: ptr.String("name 1")}, + {VPPAppID: app2.VPPAppID, SelfService: true, DisplayName: ptr.String("name 2"), Configuration: json.RawMessage(nil)}, + {VPPAppID: app3.VPPAppID, SelfService: true, DisplayName: ptr.String("name 3"), Configuration: json.RawMessage(`{}`)}, + {VPPAppID: app4.VPPAppID, SelfService: true, DisplayName: ptr.String("name 4"), Configuration: config1}, + }, map[string]uint{"1_android": 1, "2_android": 2, "3_android": 3, "4_android": 4}) + require.NoError(t, err) + + assigned, err := ds.GetAssignedVPPApps(ctx, &team.ID) + require.NoError(t, err) + require.Len(t, assigned, 4) + + for _, a := range assigned { + config, err := ds.GetAndroidAppConfiguration(ctx, a.AdamID, team.ID) + require.True(t, err == nil || fleet.IsNotFound(err)) + if config != nil { + a.Configuration = config.Configuration + assigned[a.VPPAppID] = a + } + } + + require.Equal(t, json.RawMessage(nil), assigned[app1.VPPAppID].Configuration) + require.Equal(t, json.RawMessage(nil), assigned[app2.VPPAppID].Configuration) + require.Equal(t, json.RawMessage(`{}`), assigned[app3.VPPAppID].Configuration) + require.Equal(t, expectedConfig1, assigned[app4.VPPAppID].Configuration) + + err = ds.SetTeamVPPApps(ctx, &team.ID, []fleet.VPPAppTeam{ + {VPPAppID: app1.VPPAppID}, // stays nil + {VPPAppID: app2.VPPAppID, Configuration: json.RawMessage(`{"managedConfiguration": 1}`)}, // updates + {VPPAppID: app3.VPPAppID, Configuration: json.RawMessage(nil)}, + {VPPAppID: app4.VPPAppID, Configuration: config1}, + }, map[string]uint{"1": 1, "2": 2, "3": 3, "4": 4}) + require.NoError(t, err) + + assigned, err = ds.GetAssignedVPPApps(ctx, &team.ID) + require.NoError(t, err) + require.Len(t, assigned, 4) + + for _, a := range assigned { + config, err := ds.GetAndroidAppConfiguration(ctx, a.AdamID, team.ID) + require.True(t, err == nil || fleet.IsNotFound(err)) + if config != nil { + a.Configuration = config.Configuration + assigned[a.VPPAppID] = a + } + } + + require.Equal(t, json.RawMessage(nil), assigned[app1.VPPAppID].Configuration) + require.Equal(t, json.RawMessage(`{"managedConfiguration": 1}`), assigned[app2.VPPAppID].Configuration) + require.Equal(t, json.RawMessage(`{}`), assigned[app3.VPPAppID].Configuration) + require.Equal(t, expectedConfig1, assigned[app4.VPPAppID].Configuration) + + // Delete all + err = ds.SetTeamVPPApps(ctx, &team.ID, []fleet.VPPAppTeam{}, nil) + require.NoError(t, err) +} diff --git a/server/fleet/software.go b/server/fleet/software.go index 863246a6ad..c438998ffb 100644 --- a/server/fleet/software.go +++ b/server/fleet/software.go @@ -656,11 +656,12 @@ type VPPBatchPayload struct { LabelsExcludeAny []string `json:"labels_exclude_any"` LabelsIncludeAny []string `json:"labels_include_any"` // Categories is the list of names of software categories associated with this VPP app. - Categories []string `json:"categories"` - DisplayName string `json:"display_name"` - IconPath string `json:"-"` - IconHash string `json:"-"` - Platform InstallableDevicePlatform `json:"platform"` + Categories []string `json:"categories"` + DisplayName string `json:"display_name"` + IconPath string `json:"-"` + IconHash string `json:"-"` + Platform InstallableDevicePlatform `json:"platform"` + Configuration json.RawMessage `json:"configuration,omitempty"` } func (v VPPBatchPayload) GetPlatform() string { @@ -681,8 +682,9 @@ type VPPBatchPayloadWithPlatform struct { // Categories is the list of names of software categories associated with this VPP app. Categories []string `json:"categories"` // CategoryIDs is the list of IDs of software categories associated with this VPP app. - CategoryIDs []uint `json:"-"` - DisplayName string `json:"display_name"` + CategoryIDs []uint `json:"-"` + DisplayName string `json:"display_name"` + Configuration json.RawMessage `json:"configuration,omitempty"` } type SoftwareCategory struct { diff --git a/server/fleet/teams.go b/server/fleet/teams.go index 2219598d77..c54f75e274 100644 --- a/server/fleet/teams.go +++ b/server/fleet/teams.go @@ -268,10 +268,12 @@ type TeamSpecAppStoreApp struct { Icon TeamSpecSoftwareAsset `json:"icon"` Platform string `json:"platform"` DisplayName string `json:"display_name,omitempty"` + Configuration TeamSpecSoftwareAsset `json:"configuration"` } func (spec TeamSpecAppStoreApp) ResolvePaths(baseDir string) TeamSpecAppStoreApp { spec.Icon.Path = resolveApplyRelativePath(baseDir, spec.Icon.Path) + spec.Configuration.Path = resolveApplyRelativePath(baseDir, spec.Configuration.Path) return spec } diff --git a/server/service/client.go b/server/service/client.go index 0663aa400e..db4cc2736a 100644 --- a/server/service/client.go +++ b/server/service/client.go @@ -881,7 +881,15 @@ func (c *Client) ApplyGroup( return nil, nil, nil, nil, fmt.Errorf("Couldn't edit app store app (%s). Invalid custom icon file %s: %w", app.AppStoreID, app.Icon.Path, err) } - appPayloads = append(appPayloads, fleet.VPPBatchPayload{ + var androidConfig json.RawMessage + if app.Platform == string(fleet.AndroidPlatform) { + androidConfig, err = getAndroidAppConfig(app.Configuration.Path) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("Couldn't edit app store app (%s). Reading configuration %s: %w", app.AppStoreID, app.Configuration.Path, err) + } + } + + payload := fleet.VPPBatchPayload{ AppStoreID: app.AppStoreID, SelfService: app.SelfService, InstallDuringSetup: installDuringSetup, @@ -892,7 +900,12 @@ func (c *Client) ApplyGroup( IconPath: app.Icon.Path, IconHash: iconHash, Platform: fleet.InstallableDevicePlatform(app.Platform), - }) + } + if androidConfig != nil { + payload.Configuration = androidConfig + } + appPayloads = append(appPayloads, payload) + // can be referenced by macos_setup.software.app_store_id if tmSoftwareAppsByAppID[tmName] == nil { tmSoftwareAppsByAppID[tmName] = make(map[string]fleet.TeamSpecAppStoreApp, len(apps)) @@ -1284,6 +1297,26 @@ func getIconHashIfValid(path string) (string, error) { return hash, nil } +func getAndroidAppConfig(path string) (json.RawMessage, error) { + if path == "" { + return nil, nil + } + + configReader, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading android app configuration file: %w", err) + } + + config := json.RawMessage(configReader) + + err = fleet.ValidateAndroidAppConfiguration(config) + if err != nil { + return nil, err + } + + return config, nil +} + func extractAppCfgMacOSSetup(appCfg any) *fleet.MacOSSetup { asMap, ok := appCfg.(map[string]interface{}) if !ok { @@ -2483,7 +2516,15 @@ func (c *Client) doGitOpsNoTeamSetupAndSoftware( return nil, nil, fmt.Errorf("Couldn't edit app store app (%s). Invalid custom icon file %s: %w", vppApp.AppStoreID, vppApp.Icon.Path, err) } - appsPayload = append(appsPayload, fleet.VPPBatchPayload{ + var androidConfig json.RawMessage + if vppApp.Platform == string(fleet.AndroidPlatform) { + androidConfig, err = getAndroidAppConfig(vppApp.Configuration.Path) + if err != nil { + return nil, nil, fmt.Errorf("Couldn't edit app store app (%s). Reading configuration %s: %w", vppApp.AppStoreID, vppApp.Configuration.Path, err) + } + } + + payload := fleet.VPPBatchPayload{ AppStoreID: vppApp.AppStoreID, SelfService: vppApp.SelfService, InstallDuringSetup: &installDuringSetup, @@ -2491,7 +2532,11 @@ func (c *Client) doGitOpsNoTeamSetupAndSoftware( IconPath: vppApp.Icon.Path, IconHash: iconHash, Platform: fleet.InstallableDevicePlatform(vppApp.Platform), - }) + } + if androidConfig != nil { + payload.Configuration = androidConfig + } + appsPayload = append(appsPayload, payload) } } diff --git a/server/service/integration_android_software_test.go b/server/service/integration_android_software_test.go index 6263f2b369..4d9ffb9954 100644 --- a/server/service/integration_android_software_test.go +++ b/server/service/integration_android_software_test.go @@ -277,9 +277,7 @@ func (s *integrationMDMTestSuite) TestAndroidAppsSelfService() { TeamID: nil, }, http.StatusOK, &titleWithConfigResp) - var responseConf map[string]any - require.NoError(t, json.Unmarshal(titleWithConfigResp.SoftwareTitle.AppStoreApp.Configuration, &responseConf)) - require.Contains(t, responseConf, "workProfileWidgets") + require.Contains(t, string(titleWithConfigResp.SoftwareTitle.AppStoreApp.Configuration), "workProfileWidgets") // Edit app and change configuration newConfig := json.RawMessage(`{"managedConfiguration": {"key": "value"}}`) @@ -496,3 +494,161 @@ func (s *integrationMDMTestSuite) enableAndroidMDM(t *testing.T) string { return enterpriseID } + +func (s *integrationMDMTestSuite) TestAndroidAppConfigurations() { + t := s.T() + + 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) + + 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 + } + + var createTeamResp teamResponse + s.DoJSON("POST", "/api/latest/fleet/teams", &fleet.Team{ + Name: t.Name(), + }, http.StatusOK, &createTeamResp) + require.NotZero(t, createTeamResp.Team.ID) + teamID := &createTeamResp.Team.ID + + // 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", "software_title": "%s", "software_title_id": %d, "app_store_id": "%s", "team_id": %d, "platform": "%s", "self_service": true,"configuration": %s}`, + t.Name(), "Test App", appWithConfigResp.TitleID, androidAppFoo.AdamID, 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", t.Name(), + ) + + s.DoJSON("GET", "/api/latest/fleet/software/titles", nil, http.StatusOK, &listSWTitles, "team_id", fmt.Sprint(*teamID)) + s.Assert().Len(listSWTitles.SoftwareTitles, 4) + s.Assert().Equal("app_1", listSWTitles.SoftwareTitles[0].AppStoreApp.AppStoreID) + titleApp1 := listSWTitles.SoftwareTitles[0].ID + titleApp2 := listSWTitles.SoftwareTitles[1].ID + + // 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", t.Name(), + ) + + 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", t.Name(), + ) + + 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"`) +} diff --git a/tools/cloner-check/generated_files/teamconfig.txt b/tools/cloner-check/generated_files/teamconfig.txt index f8ce43152c..3b72215e94 100644 --- a/tools/cloner-check/generated_files/teamconfig.txt +++ b/tools/cloner-check/generated_files/teamconfig.txt @@ -141,3 +141,4 @@ github.com/fleetdm/fleet/v4/server/fleet/TeamSpecAppStoreApp InstallDuringSetup github.com/fleetdm/fleet/v4/server/fleet/TeamSpecAppStoreApp Icon fleet.TeamSpecSoftwareAsset github.com/fleetdm/fleet/v4/server/fleet/TeamSpecAppStoreApp Platform string github.com/fleetdm/fleet/v4/server/fleet/TeamSpecAppStoreApp DisplayName string +github.com/fleetdm/fleet/v4/server/fleet/TeamSpecAppStoreApp Configuration fleet.TeamSpecSoftwareAsset