From c274ebc63b8f83294dcc6b239052a5754dd020a0 Mon Sep 17 00:00:00 2001 From: Jonathan Katz <44128041+jkatz01@users.noreply.github.com> Date: Tue, 25 Nov 2025 11:21:14 -0500 Subject: [PATCH] 35493 Android software configurations API endpoints (#36096) **Related issue:** Resolves #35493 Notes: - Currently this adds a new function `updateAndroidAppConfigurationTx` that uses a passed transaction to stay consistent with how uploading/editing vpp apps treats display names and custom icons. - In some places configuration uses `omitempty` to use `json.RawMessage` but avoid it being set to "null" in requests/respones. # Checklist for submitter ## Testing - [x] Added/updated automated tests - [ ] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [x] QA'd all new/changed functionality manually - Can add an app with empty configuration {} - Can delete the app, and configuration deletes as well - Can't add app with invalid configuration - "reason": "Couldn't update configuration. Only \"managedConfiguration\" and \"workProfileWidgets\" are supported as top-level keys." - Can add an app with a good configuration - Can edit app and change the configuration to something valid, invalid gives error For unreleased bug fixes in a release candidate, one of: - [ ] Confirmed that the fix is not expected to adversely impact load test results - [ ] Alerted the release DRI if additional load testing is needed --- ee/server/service/vpp.go | 34 +++++--- server/datastore/mysql/android.go | 32 ++++++++ server/datastore/mysql/android_test.go | 69 ++++++++++++++++ server/datastore/mysql/vpp.go | 22 ++++++ server/fleet/activities.go | 2 + server/fleet/service.go | 2 +- server/fleet/vpp.go | 18 ++++- server/mock/service/service_mock.go | 6 +- .../integration_android_software_test.go | 79 +++++++++++++++++++ server/service/vpp.go | 29 ++++--- 10 files changed, 266 insertions(+), 27 deletions(-) diff --git a/ee/server/service/vpp.go b/ee/server/service/vpp.go index fbf20326e2..adc0c88c49 100644 --- a/ee/server/service/vpp.go +++ b/ee/server/service/vpp.go @@ -449,6 +449,9 @@ func (svc *Service) AddAppStoreApp(ctx context.Context, teamID *uint, appID flee assetMD := assetMetadata[asset.AdamID] + // Configuration is an Android only feature + appID.Configuration = nil + platforms := getPlatformsFromSupportedDevices(assetMD.SupportedDevices) if _, ok := platforms[appID.Platform]; !ok { return 0, fleet.NewInvalidArgumentError("app_store_id", fmt.Sprintf("%s isn't available for %s", assetMD.TrackName, appID.Platform)) @@ -512,6 +515,7 @@ func (svc *Service) AddAppStoreApp(ctx context.Context, teamID *uint, appID flee SelfService: app.SelfService, LabelsIncludeAny: actLabelsIncl, LabelsExcludeAny: actLabelsExcl, + Configuration: app.Configuration, } if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil { @@ -604,7 +608,7 @@ func getVPPAppsMetadata(ctx context.Context, ids []fleet.VPPAppTeam) ([]*fleet.V return apps, nil } -func (svc *Service) UpdateAppStoreApp(ctx context.Context, titleID uint, teamID *uint, selfService *bool, labelsIncludeAny, labelsExcludeAny, categories []string, displayName *string) (*fleet.VPPAppStoreApp, error) { +func (svc *Service) UpdateAppStoreApp(ctx context.Context, titleID uint, teamID *uint, payload fleet.AppStoreAppUpdatePayload) (*fleet.VPPAppStoreApp, error) { if err := svc.authz.Authorize(ctx, &fleet.VPPApp{TeamID: teamID}, fleet.ActionWrite); err != nil { return nil, err } @@ -623,9 +627,9 @@ func (svc *Service) UpdateAppStoreApp(ctx context.Context, titleID uint, teamID } var validatedLabels *fleet.LabelIdentsWithScope - if labelsExcludeAny != nil || labelsIncludeAny != nil { + if payload.LabelsExcludeAny != nil || payload.LabelsIncludeAny != nil { var err error - validatedLabels, err = ValidateSoftwareLabels(ctx, svc, labelsIncludeAny, labelsExcludeAny) + validatedLabels, err = ValidateSoftwareLabels(ctx, svc, payload.LabelsIncludeAny, payload.LabelsExcludeAny) if err != nil { return nil, ctxerr.Wrap(ctx, err, "UpdateAppStoreApp: validating software labels") } @@ -637,8 +641,8 @@ func (svc *Service) UpdateAppStoreApp(ctx context.Context, titleID uint, teamID } selfServiceVal := meta.SelfService - if selfService != nil { - selfServiceVal = *selfService + if payload.SelfService != nil { + selfServiceVal = *payload.SelfService } appToWrite := &fleet.VPPApp{ @@ -648,7 +652,8 @@ func (svc *Service) UpdateAppStoreApp(ctx context.Context, titleID uint, teamID }, SelfService: selfServiceVal, ValidatedLabels: validatedLabels, - DisplayName: displayName, + DisplayName: payload.DisplayName, + Configuration: payload.Configuration, }, TeamID: teamID, TitleID: titleID, @@ -660,23 +665,27 @@ func (svc *Service) UpdateAppStoreApp(ctx context.Context, titleID uint, teamID appToWrite.IconURL = *meta.IconURL } - if categories != nil { - categories = server.RemoveDuplicatesFromSlice(categories) - catIDs, err := svc.ds.GetSoftwareCategoryIDs(ctx, categories) + if payload.Categories != nil { + payload.Categories = server.RemoveDuplicatesFromSlice(payload.Categories) + catIDs, err := svc.ds.GetSoftwareCategoryIDs(ctx, payload.Categories) if err != nil { return nil, ctxerr.Wrap(ctx, err, "getting software category ids") } - if len(catIDs) != len(categories) { + if len(catIDs) != len(payload.Categories) { return nil, &fleet.BadRequestError{ Message: "some or all of the categories provided don't exist", - InternalErr: fmt.Errorf("categories provided: %v", categories), + InternalErr: fmt.Errorf("categories provided: %v", payload.Categories), } } appToWrite.CategoryIDs = catIDs } + if payload.Configuration != nil { + appToWrite.Configuration = payload.Configuration + } + // check if labels have changed var existingLabels fleet.LabelIdentsWithScope switch { @@ -738,7 +747,7 @@ func (svc *Service) UpdateAppStoreApp(ctx context.Context, titleID uint, teamID actLabelsIncl, actLabelsExcl := activitySoftwareLabelsFromValidatedLabels(validatedLabels) - displayNameVal := ptr.ValOrZero(displayName) + displayNameVal := ptr.ValOrZero(payload.DisplayName) act := fleet.ActivityEditedAppStoreApp{ TeamName: &teamName, @@ -752,6 +761,7 @@ func (svc *Service) UpdateAppStoreApp(ctx context.Context, titleID uint, teamID LabelsExcludeAny: actLabelsExcl, SoftwareIconURL: meta.IconURL, SoftwareDisplayName: displayNameVal, + Configuration: appToWrite.Configuration, } if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil { return nil, ctxerr.Wrap(ctx, err, "create activity for update app store app") diff --git a/server/datastore/mysql/android.go b/server/datastore/mysql/android.go index d5bd74a2ff..e41f81f75e 100644 --- a/server/datastore/mysql/android.go +++ b/server/datastore/mysql/android.go @@ -1624,3 +1624,35 @@ func (ds *Datastore) DeleteAndroidAppConfiguration(ctx context.Context, appID st return nil } + +// updateAndroidAppConfigurationTx inserts or updates an app configuration using a transaction +func (ds *Datastore) updateAndroidAppConfigurationTx(ctx context.Context, tx sqlx.ExtContext, teamID *uint, appID string, config json.RawMessage) error { + err := fleet.ValidateAndroidAppConfiguration(config) + if err != nil { + 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) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + configuration = VALUES(configuration) + ` + + _, err = tx.ExecContext(ctx, stmt, appID, tid, globalOrTeamID, config) + if err != nil { + return ctxerr.Wrap(ctx, err, "updateAndroidAppConfiguration") + } + return nil +} diff --git a/server/datastore/mysql/android_test.go b/server/datastore/mysql/android_test.go index 629dcdfe85..4c5ad0f3cb 100644 --- a/server/datastore/mysql/android_test.go +++ b/server/datastore/mysql/android_test.go @@ -59,6 +59,7 @@ func TestAndroid(t *testing.T) { {"InsertAndroidAppConfiguration_Duplicate", testInsertAndroidAppConfigurationDuplicate}, {"AndroidAppConfiguration_CascadeDeleteTeam", testAndroidAppConfigurationCascadeDeleteTeam}, {"AndroidAppConfiguration_GlobalVsTeam", testAndroidAppConfigurationGlobalVsTeam}, + {"AddDeleteAndroidAppWithConfiguration", testAddDeleteAndroidAppWithConfiguration}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -2538,3 +2539,71 @@ func testAndroidAppConfigurationGlobalVsTeam(t *testing.T, ds *Datastore) { require.Equal(t, teamID, *retrievedTeam.TeamID) require.Equal(t, teamID, retrievedTeam.GlobalOrTeamID) } + +func testAddDeleteAndroidAppWithConfiguration(t *testing.T, ds *Datastore) { + ctx := context.Background() + + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) + require.NoError(t, err) + + test.CreateInsertGlobalVPPToken(t, ds) + + testConfig := json.RawMessage(`{"ManagedConfiguration": {"DisableShareScreen": true, "DisableComputerAudio": true}}`) + // Create android and VPP apps + app1, err := ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{ + Name: "android1", BundleIdentifier: "android1", + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "something_android_app_1", Platform: fleet.AndroidPlatform}, + Configuration: testConfig, + }}, &team1.ID) + require.NoError(t, err) + + app2, err := ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{ + Name: "vpp1", BundleIdentifier: "com.app.vpp1", + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_forapple_1", Platform: fleet.IOSPlatform}, + Configuration: json.RawMessage(`{"ManagedConfiguration": {"ios app shouldn't have configuration": true}}`), + }}, &team1.ID) + require.NoError(t, err) + + // Get android app without team + meta, err := ds.GetVPPAppMetadataByTeamAndTitleID(ctx, nil, app1.TitleID) + require.NoError(t, err) + require.Zero(t, meta.Configuration) + + // Get android app and configuration + meta, err = ds.GetVPPAppMetadataByTeamAndTitleID(ctx, &team1.ID, app1.TitleID) + require.NoError(t, err) + require.NotZero(t, meta.VPPAppsTeamsID) + require.NotZero(t, meta.Configuration) + require.Equal(t, "android1", meta.BundleIdentifier) + require.Equal(t, testConfig, meta.Configuration) + + // Get ios app + meta2, err := ds.GetVPPAppMetadataByTeamAndTitleID(ctx, nil, app2.TitleID) + require.NoError(t, err) + require.NotZero(t, meta2.VPPAppsTeamsID) + + // Edit android app + newConfig := json.RawMessage(`{"workProfileWidgets": "WORK_PROFILE_WIDGETS_ALLOWED"}`) + app1.VPPAppTeam.Configuration = newConfig + _, err = ds.InsertVPPAppWithTeam(ctx, app1, &team1.ID) + require.NoError(t, err) + + // Check that configuration was changed + meta, err = ds.GetVPPAppMetadataByTeamAndTitleID(ctx, &team1.ID, app1.TitleID) + require.NoError(t, err) + require.NotZero(t, meta.VPPAppsTeamsID) + require.Equal(t, newConfig, meta.Configuration) + + // Add invalid configuration + badConfig := json.RawMessage(`"-": "-"`) + app1.VPPAppTeam.Configuration = badConfig + _, err = ds.InsertVPPAppWithTeam(ctx, app1, &team1.ID) + require.Error(t, err) + + // Delete app, should delete configuration + require.NoError(t, ds.DeleteVPPAppFromTeam(ctx, &team1.ID, app1.VPPAppID)) + _, err = ds.GetVPPAppMetadataByTeamAndTitleID(ctx, &team1.ID, app1.TitleID) + require.ErrorContains(t, err, "not found") + _, err = ds.GetAndroidAppConfiguration(ctx, app1.AdamID, team1.ID) + require.ErrorContains(t, err, "not found") +} diff --git a/server/datastore/mysql/vpp.go b/server/datastore/mysql/vpp.go index 299129b53e..7fe2f76086 100644 --- a/server/datastore/mysql/vpp.go +++ b/server/datastore/mysql/vpp.go @@ -96,6 +96,14 @@ WHERE app.DisplayName = displayName + config, err := ds.GetAndroidAppConfiguration(ctx, app.AdamID, tmID) // tmID can be used as globalOrTeamID + if err != nil && !fleet.IsNotFound(err) { + return nil, ctxerr.Wrap(ctx, err, "get android configuration for app store app") + } + if config != nil && config.Configuration != nil { + app.Configuration = config.Configuration + } + if teamID != nil { policies, err := ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{titleID}, *teamID) if err != nil { @@ -642,6 +650,12 @@ func (ds *Datastore) InsertVPPAppWithTeam(ctx context.Context, app *fleet.VPPApp } } + if app.Configuration != nil && app.Platform == fleet.AndroidPlatform { + if err := ds.updateAndroidAppConfigurationTx(ctx, tx, teamID, app.AdamID, app.Configuration); err != nil { + return ctxerr.Wrap(ctx, err, "setting configuration for android app") + } + } + return nil }) if err != nil { @@ -905,6 +919,14 @@ func (ds *Datastore) DeleteVPPAppFromTeam(ctx context.Context, teamID *uint, app return notFound("VPPApp").WithMessage(fmt.Sprintf("adam id %s platform %s for team id %d", appID.AdamID, appID.Platform, globalOrTeamID)) } + + if appID.Platform == fleet.AndroidPlatform { + err := ds.DeleteAndroidAppConfiguration(ctx, appID.AdamID, globalOrTeamID) + if err != nil && !fleet.IsNotFound(err) { + return ctxerr.Wrap(ctx, err, "deleting android app configuration") + } + } + return nil } diff --git a/server/fleet/activities.go b/server/fleet/activities.go index c74f9f7a51..59e29bcd80 100644 --- a/server/fleet/activities.go +++ b/server/fleet/activities.go @@ -2262,6 +2262,7 @@ type ActivityAddedAppStoreApp struct { SelfService bool `json:"self_service"` LabelsIncludeAny []ActivitySoftwareLabel `json:"labels_include_any,omitempty"` LabelsExcludeAny []ActivitySoftwareLabel `json:"labels_exclude_any,omitempty"` + Configuration json.RawMessage `json:"configuration,omitempty"` } func (a ActivityAddedAppStoreApp) ActivityName() string { @@ -2413,6 +2414,7 @@ type ActivityEditedAppStoreApp struct { LabelsIncludeAny []ActivitySoftwareLabel `json:"labels_include_any,omitempty"` LabelsExcludeAny []ActivitySoftwareLabel `json:"labels_exclude_any,omitempty"` SoftwareDisplayName string `json:"software_display_name"` + Configuration json.RawMessage `json:"configuration,omitempty"` } func (a ActivityEditedAppStoreApp) ActivityName() string { diff --git a/server/fleet/service.go b/server/fleet/service.go index e9b51c9079..b9db6f72ef 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -728,7 +728,7 @@ type Service interface { // AddAppStoreApp persists a VPP app onto a team and returns the resulting title ID AddAppStoreApp(ctx context.Context, teamID *uint, appTeam VPPAppTeam) (uint, error) - UpdateAppStoreApp(ctx context.Context, titleID uint, teamID *uint, selfService *bool, labelsIncludeAny, labelsExcludeAny, categories []string, displayName *string) (*VPPAppStoreApp, error) + UpdateAppStoreApp(ctx context.Context, titleID uint, teamID *uint, payload AppStoreAppUpdatePayload) (*VPPAppStoreApp, error) // GetInHouseAppManifest returns a manifest XML file that points at the download URL for the given in-house app. GetInHouseAppManifest(ctx context.Context, titleID uint, teamID *uint) ([]byte, error) diff --git a/server/fleet/vpp.go b/server/fleet/vpp.go index bfc36b77ba..3a98896835 100644 --- a/server/fleet/vpp.go +++ b/server/fleet/vpp.go @@ -1,6 +1,7 @@ package fleet import ( + "encoding/json" "fmt" "time" ) @@ -47,6 +48,9 @@ type VPPAppTeam struct { // app creation if AddAutoInstallPolicy is true. AddedAutomaticInstallPolicy *Policy `json:"-"` DisplayName *string `json:"display_name"` + // Configuration is a json file used to customize Android app + // behavior/settings. Relevant to Android only. + Configuration json.RawMessage `json:"configuration,omitempty"` } // VPPApp represents a VPP (Volume Purchase Program) application, @@ -102,8 +106,9 @@ type VPPAppStoreApp struct { AddedAt time.Time `db:"added_at" json:"created_at"` // Categories is the list of categories to which this software belongs: e.g. "Productivity", // "Browsers", etc. - Categories []string `json:"categories"` - DisplayName string `json:"display_name"` + Categories []string `json:"categories"` + DisplayName string `json:"display_name"` + Configuration json.RawMessage `json:"configuration"` } // VPPAppStatusSummary represents aggregated status metrics for a VPP app. @@ -138,3 +143,12 @@ const ( DefaultVPPInstallVerifyTimeout = 10 * time.Minute DefaultVPPVerifyRequestDelay = 5 * time.Second ) + +type AppStoreAppUpdatePayload struct { + SelfService *bool + LabelsIncludeAny []string + LabelsExcludeAny []string + Categories []string + DisplayName *string + Configuration json.RawMessage +} diff --git a/server/mock/service/service_mock.go b/server/mock/service/service_mock.go index f16c94cc69..ef9b8336a0 100644 --- a/server/mock/service/service_mock.go +++ b/server/mock/service/service_mock.go @@ -470,7 +470,7 @@ type GetAppStoreAppsFunc func(ctx context.Context, teamID *uint) ([]*fleet.VPPAp type AddAppStoreAppFunc func(ctx context.Context, teamID *uint, appTeam fleet.VPPAppTeam) (uint, error) -type UpdateAppStoreAppFunc func(ctx context.Context, titleID uint, teamID *uint, selfService *bool, labelsIncludeAny []string, labelsExcludeAny []string, categories []string, displayName *string) (*fleet.VPPAppStoreApp, error) +type UpdateAppStoreAppFunc func(ctx context.Context, titleID uint, teamID *uint, payload fleet.AppStoreAppUpdatePayload) (*fleet.VPPAppStoreApp, error) type GetInHouseAppManifestFunc func(ctx context.Context, titleID uint, teamID *uint) ([]byte, error) @@ -3719,11 +3719,11 @@ func (s *Service) AddAppStoreApp(ctx context.Context, teamID *uint, appTeam flee return s.AddAppStoreAppFunc(ctx, teamID, appTeam) } -func (s *Service) UpdateAppStoreApp(ctx context.Context, titleID uint, teamID *uint, selfService *bool, labelsIncludeAny []string, labelsExcludeAny []string, categories []string, displayName *string) (*fleet.VPPAppStoreApp, error) { +func (s *Service) UpdateAppStoreApp(ctx context.Context, titleID uint, teamID *uint, payload fleet.AppStoreAppUpdatePayload) (*fleet.VPPAppStoreApp, error) { s.mu.Lock() s.UpdateAppStoreAppFuncInvoked = true s.mu.Unlock() - return s.UpdateAppStoreAppFunc(ctx, titleID, teamID, selfService, labelsIncludeAny, labelsExcludeAny, categories, displayName) + return s.UpdateAppStoreAppFunc(ctx, titleID, teamID, payload) } func (s *Service) GetInHouseAppManifest(ctx context.Context, titleID uint, teamID *uint) ([]byte, error) { diff --git a/server/service/integration_android_software_test.go b/server/service/integration_android_software_test.go index 94b7f3af78..8d00622361 100644 --- a/server/service/integration_android_software_test.go +++ b/server/service/integration_android_software_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "fmt" "net/http" "net/url" @@ -221,4 +222,82 @@ func (s *integrationMDMTestSuite) TestAndroidAppSelfService() { // Should have hit the android API endpoint s.Assert().True(s.androidAPIClient.EnterprisesPoliciesModifyPolicyApplicationsFuncInvoked) + // Test Android app configurations + + // 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", + } + + // 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", "software_title": "%s", "software_title_id": %d, "app_store_id": "%s", "team_id": %s, "platform": "%s", "self_service": true,"configuration": %s}`, + "", "Test App", appWithConfigResp.TitleID, androidAppWithConfig.AdamID, "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) + + var responseConf map[string]any + require.NoError(t, json.Unmarshal(titleWithConfigResp.SoftwareTitle.AppStoreApp.Configuration, &responseConf)) + require.Contains(t, responseConf, "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", "software_title": "%s", "software_icon_url":"https://example.com/1.jpg", "software_title_id": %d, "app_store_id": "%s", "team_id": %s, "software_display_name":"", "platform": "%s", "self_service": true,"configuration": %s}`, + "", "Test App", appWithConfigResp.TitleID, androidAppWithConfig.AdamID, "null", androidAppWithConfig.Platform, newConfig), 0) } diff --git a/server/service/vpp.go b/server/service/vpp.go index 779bf6379a..4e93ef9317 100644 --- a/server/service/vpp.go +++ b/server/service/vpp.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "io" "mime/multipart" "net/http" @@ -58,6 +59,7 @@ type addAppStoreAppRequest struct { LabelsIncludeAny []string `json:"labels_include_any"` LabelsExcludeAny []string `json:"labels_exclude_any"` Categories []string `json:"categories"` + Configuration json.RawMessage `json:"configuration,omitempty"` } type addAppStoreAppResponse struct { @@ -76,6 +78,7 @@ func addAppStoreAppEndpoint(ctx context.Context, request interface{}, svc fleet. LabelsExcludeAny: req.LabelsExcludeAny, AddAutoInstallPolicy: req.AutomaticInstall, Categories: req.Categories, + Configuration: req.Configuration, }) if err != nil { return &addAppStoreAppResponse{Err: err}, nil @@ -97,13 +100,14 @@ func (svc *Service) AddAppStoreApp(ctx context.Context, _ *uint, _ fleet.VPPAppT ////////////////////////////////////////////////////////////////////////////// type updateAppStoreAppRequest struct { - TitleID uint `url:"title_id"` - TeamID *uint `json:"team_id"` - SelfService *bool `json:"self_service"` - LabelsIncludeAny []string `json:"labels_include_any"` - LabelsExcludeAny []string `json:"labels_exclude_any"` - Categories []string `json:"categories"` - DisplayName *string `json:"display_name"` + TitleID uint `url:"title_id"` + TeamID *uint `json:"team_id"` + SelfService *bool `json:"self_service"` + LabelsIncludeAny []string `json:"labels_include_any"` + LabelsExcludeAny []string `json:"labels_exclude_any"` + Categories []string `json:"categories"` + Configuration json.RawMessage `json:"configuration,omitempty"` + DisplayName *string `json:"display_name"` } type updateAppStoreAppResponse struct { @@ -116,7 +120,14 @@ func (r updateAppStoreAppResponse) Error() error { return r.Err } func updateAppStoreAppEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { req := request.(*updateAppStoreAppRequest) - updatedApp, err := svc.UpdateAppStoreApp(ctx, req.TitleID, req.TeamID, req.SelfService, req.LabelsIncludeAny, req.LabelsExcludeAny, req.Categories, req.DisplayName) + updatedApp, err := svc.UpdateAppStoreApp(ctx, req.TitleID, req.TeamID, fleet.AppStoreAppUpdatePayload{ + SelfService: req.SelfService, + LabelsIncludeAny: req.LabelsIncludeAny, + LabelsExcludeAny: req.LabelsExcludeAny, + Categories: req.Categories, + Configuration: req.Configuration, + DisplayName: req.DisplayName, + }) if err != nil { return updateAppStoreAppResponse{Err: err}, nil } @@ -124,7 +135,7 @@ func updateAppStoreAppEndpoint(ctx context.Context, request interface{}, svc fle return updateAppStoreAppResponse{AppStoreApp: updatedApp}, nil } -func (svc *Service) UpdateAppStoreApp(ctx context.Context, titleID uint, teamID *uint, selfService *bool, labelsIncludeAny, labelsExcludeAny, categories []string, displayName *string) (*fleet.VPPAppStoreApp, error) { +func (svc *Service) UpdateAppStoreApp(ctx context.Context, titleID uint, teamID *uint, payload fleet.AppStoreAppUpdatePayload) (*fleet.VPPAppStoreApp, error) { // skipauth: No authorization check needed due to implementation returning // only license error. svc.authz.SkipAuthorization(ctx)