fleet/server/service/integration_android_software_test.go

1402 lines
58 KiB
Go
Raw Normal View History

package service
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"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.Contains(extractServerErrorText(r.Body), "Couldn't add software. \"com.valid.app.id\" isn't available in Apple Business or Play Store. Please purchase a license in Apple Business 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, &notFoundError{}
}
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 \"com.app.id.not.found\" 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 or Play Store. Please purchase a license in Apple Business 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(),
Deprecate "team" and "query" API params (#39873) <!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** For #39344 # Details This PR builds on the previous PR (https://github.com/fleetdm/fleet/pull/39847) which added `renameto` tags to certain API parameters to mark them as deprecated. How this is used: ### In requests * When decoding requests, log a warning if a `json` or `query` param is used that has a `renameto` tag, e.g. if a `team_id` param is sent but the related struct has `renameto:"fleet_id"` in it. * If the `renamedto` version (e.g. `fleet_id`) is sent in the request, rewrite it to the deprecated name so that it can be unmarshalled into the struct * If both versions are sent (e.g. `team_id` AND `fleet_id`), throw an error and quit * URLs with deprecated terms have new aliases using `WithAltPaths` -- warning on using old URLSs a TODO that will be handled in a subsequent PR. ### In responses * Output _both_ the deprecated and new names for fields that have `renameto` tags, so that we don't break existing workflows expecting the old keys. Uses a shared `DuplicateJSONKeys` to do the duplication. * Most API responses are handled in `EncodeCommonResponse`. Exceptions are activities, failing policy webhooks and the streaming "list hosts" endpoints which call the function directly. ### In fleetctl * Similar to requests, log warnings when deprecated keys are used and rewrite the new keys internally so that they can be unmarshalled. * For `fleetctl get` and `fleetctl generate-gitops`, _only_ output the new names * The set of keys to replace is hardcoded in `fleetctl` rather than being dynamically generated as it is for API endpoints. Given the mixture of typed and untyped data and the level of nesting, dynamic map generation was very fragile and error-prone. ### Performance considerations * The biggest performance hit is the addition of the JSON key rewriter to the request pipeline. The rewriter buffers the entire request into memory before eventually passing it to the decoder than unmarshals the data into structs. I tried implementing this as a true streaming rewriter but encountered issues where the request would hang if the downstream reader (the decoder) encountered any errors. It's possible we could implement this in a streaming fashion if we replace our [current request decoder](https://github.com/fleetdm/fleet/blob/da43bf8371695382c4af0972d5da456c6b94bdaf/server/service/endpoint_utils.go#L108) with the v2 version, which is a bigger change requiring more thoughtful discussion in the engineering team. As it stands, memory usage for requests with deprecated fields will double while the request is being decoded. * The "alias rules" used to determine the old and new key names are cached per struct type and for most endpoints are generated on server start, so no performance impact is expected. * Some `fleetctl` commands may have an extra unmarshal/marshal step but as these are user-initiated and not performed in tight loops, the impact should be minimal. ### TODO * Log deprecation warnings when old URLs like "/fleet/teams" are used * Update API fields that the front-end uses to avoid deprecation warnings * Update `fleetctl apply` to accept/return `kind: fleet` rather than `kind: team` * Find/update any fleet server config vars with old language * Update all error messages that use old language # Checklist for submitter If some of the following don't apply, delete the relevant line. - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. - [X] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) ## Testing - [X] Added/updated automated tests - [X] 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 * Clicking around the front-end, no broken pages due to request ingestion errors or bad responses * Looking in network tab to verify that responses have both the old and new keys * Running `fleetctl generate-gitops` and verifying that the output looks correct and can be ingested by `fleetctl gitops` * Running `fleetctl get` and `fleetctl apply` --------- Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com>
2026-02-19 19:53:32 +00:00
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)
}
Point to com.fleetdm.agent Android agent by default. (#37770) <!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #37736 Enabling the public Android agent for Android MDM, by default - `com.fleetdm.agent` Also bug fix: [Preserve Fleet Agent in Android policy during GitOps/API app updates.](https://github.com/fleetdm/fleet/pull/37770/commits/9b3ccf55dc6f09078271d1f6a2e411e2220ee81d) # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually For unreleased bug fixes in a release candidate, one of: - [x] Confirmed that the fix is not expected to adversely impact load test results <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Release Notes * **New Features** * Added Android agent application with automatic deployment via Android MDM to support SCEP certificate management on Android devices. * Introduced configurable Android agent settings for package name and signing certificate. * **Documentation** * Updated Android MDM configuration documentation with environment variable and YAML configuration examples for Android agent deployment. <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-01-07 00:11:23 +00:00
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(),
Deprecate "team" and "query" API params (#39873) <!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** For #39344 # Details This PR builds on the previous PR (https://github.com/fleetdm/fleet/pull/39847) which added `renameto` tags to certain API parameters to mark them as deprecated. How this is used: ### In requests * When decoding requests, log a warning if a `json` or `query` param is used that has a `renameto` tag, e.g. if a `team_id` param is sent but the related struct has `renameto:"fleet_id"` in it. * If the `renamedto` version (e.g. `fleet_id`) is sent in the request, rewrite it to the deprecated name so that it can be unmarshalled into the struct * If both versions are sent (e.g. `team_id` AND `fleet_id`), throw an error and quit * URLs with deprecated terms have new aliases using `WithAltPaths` -- warning on using old URLSs a TODO that will be handled in a subsequent PR. ### In responses * Output _both_ the deprecated and new names for fields that have `renameto` tags, so that we don't break existing workflows expecting the old keys. Uses a shared `DuplicateJSONKeys` to do the duplication. * Most API responses are handled in `EncodeCommonResponse`. Exceptions are activities, failing policy webhooks and the streaming "list hosts" endpoints which call the function directly. ### In fleetctl * Similar to requests, log warnings when deprecated keys are used and rewrite the new keys internally so that they can be unmarshalled. * For `fleetctl get` and `fleetctl generate-gitops`, _only_ output the new names * The set of keys to replace is hardcoded in `fleetctl` rather than being dynamically generated as it is for API endpoints. Given the mixture of typed and untyped data and the level of nesting, dynamic map generation was very fragile and error-prone. ### Performance considerations * The biggest performance hit is the addition of the JSON key rewriter to the request pipeline. The rewriter buffers the entire request into memory before eventually passing it to the decoder than unmarshals the data into structs. I tried implementing this as a true streaming rewriter but encountered issues where the request would hang if the downstream reader (the decoder) encountered any errors. It's possible we could implement this in a streaming fashion if we replace our [current request decoder](https://github.com/fleetdm/fleet/blob/da43bf8371695382c4af0972d5da456c6b94bdaf/server/service/endpoint_utils.go#L108) with the v2 version, which is a bigger change requiring more thoughtful discussion in the engineering team. As it stands, memory usage for requests with deprecated fields will double while the request is being decoded. * The "alias rules" used to determine the old and new key names are cached per struct type and for most endpoints are generated on server start, so no performance impact is expected. * Some `fleetctl` commands may have an extra unmarshal/marshal step but as these are user-initiated and not performed in tight loops, the impact should be minimal. ### TODO * Log deprecation warnings when old URLs like "/fleet/teams" are used * Update API fields that the front-end uses to avoid deprecation warnings * Update `fleetctl apply` to accept/return `kind: fleet` rather than `kind: team` * Find/update any fleet server config vars with old language * Update all error messages that use old language # Checklist for submitter If some of the following don't apply, delete the relevant line. - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. - [X] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) ## Testing - [X] Added/updated automated tests - [X] 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 * Clicking around the front-end, no broken pages due to request ingestion errors or bad responses * Looking in network tab to verify that responses have both the old and new keys * Running `fleetctl generate-gitops` and verifying that the output looks correct and can be ingested by `fleetctl gitops` * Running `fleetctl get` and `fleetctl apply` --------- Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com>
2026-02-19 19:53:32 +00:00
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(),
Deprecate "team" and "query" API params (#39873) <!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** For #39344 # Details This PR builds on the previous PR (https://github.com/fleetdm/fleet/pull/39847) which added `renameto` tags to certain API parameters to mark them as deprecated. How this is used: ### In requests * When decoding requests, log a warning if a `json` or `query` param is used that has a `renameto` tag, e.g. if a `team_id` param is sent but the related struct has `renameto:"fleet_id"` in it. * If the `renamedto` version (e.g. `fleet_id`) is sent in the request, rewrite it to the deprecated name so that it can be unmarshalled into the struct * If both versions are sent (e.g. `team_id` AND `fleet_id`), throw an error and quit * URLs with deprecated terms have new aliases using `WithAltPaths` -- warning on using old URLSs a TODO that will be handled in a subsequent PR. ### In responses * Output _both_ the deprecated and new names for fields that have `renameto` tags, so that we don't break existing workflows expecting the old keys. Uses a shared `DuplicateJSONKeys` to do the duplication. * Most API responses are handled in `EncodeCommonResponse`. Exceptions are activities, failing policy webhooks and the streaming "list hosts" endpoints which call the function directly. ### In fleetctl * Similar to requests, log warnings when deprecated keys are used and rewrite the new keys internally so that they can be unmarshalled. * For `fleetctl get` and `fleetctl generate-gitops`, _only_ output the new names * The set of keys to replace is hardcoded in `fleetctl` rather than being dynamically generated as it is for API endpoints. Given the mixture of typed and untyped data and the level of nesting, dynamic map generation was very fragile and error-prone. ### Performance considerations * The biggest performance hit is the addition of the JSON key rewriter to the request pipeline. The rewriter buffers the entire request into memory before eventually passing it to the decoder than unmarshals the data into structs. I tried implementing this as a true streaming rewriter but encountered issues where the request would hang if the downstream reader (the decoder) encountered any errors. It's possible we could implement this in a streaming fashion if we replace our [current request decoder](https://github.com/fleetdm/fleet/blob/da43bf8371695382c4af0972d5da456c6b94bdaf/server/service/endpoint_utils.go#L108) with the v2 version, which is a bigger change requiring more thoughtful discussion in the engineering team. As it stands, memory usage for requests with deprecated fields will double while the request is being decoded. * The "alias rules" used to determine the old and new key names are cached per struct type and for most endpoints are generated on server start, so no performance impact is expected. * Some `fleetctl` commands may have an extra unmarshal/marshal step but as these are user-initiated and not performed in tight loops, the impact should be minimal. ### TODO * Log deprecation warnings when old URLs like "/fleet/teams" are used * Update API fields that the front-end uses to avoid deprecation warnings * Update `fleetctl apply` to accept/return `kind: fleet` rather than `kind: team` * Find/update any fleet server config vars with old language * Update all error messages that use old language # Checklist for submitter If some of the following don't apply, delete the relevant line. - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. - [X] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) ## Testing - [X] Added/updated automated tests - [X] 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 * Clicking around the front-end, no broken pages due to request ingestion errors or bad responses * Looking in network tab to verify that responses have both the old and new keys * Running `fleetctl generate-gitops` and verifying that the output looks correct and can be ingested by `fleetctl gitops` * Running `fleetctl get` and `fleetctl apply` --------- Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com>
2026-02-19 19:53:32 +00:00
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, &notFoundError{}
}
// 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(),
Deprecate "team" and "query" API params (#39873) <!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** For #39344 # Details This PR builds on the previous PR (https://github.com/fleetdm/fleet/pull/39847) which added `renameto` tags to certain API parameters to mark them as deprecated. How this is used: ### In requests * When decoding requests, log a warning if a `json` or `query` param is used that has a `renameto` tag, e.g. if a `team_id` param is sent but the related struct has `renameto:"fleet_id"` in it. * If the `renamedto` version (e.g. `fleet_id`) is sent in the request, rewrite it to the deprecated name so that it can be unmarshalled into the struct * If both versions are sent (e.g. `team_id` AND `fleet_id`), throw an error and quit * URLs with deprecated terms have new aliases using `WithAltPaths` -- warning on using old URLSs a TODO that will be handled in a subsequent PR. ### In responses * Output _both_ the deprecated and new names for fields that have `renameto` tags, so that we don't break existing workflows expecting the old keys. Uses a shared `DuplicateJSONKeys` to do the duplication. * Most API responses are handled in `EncodeCommonResponse`. Exceptions are activities, failing policy webhooks and the streaming "list hosts" endpoints which call the function directly. ### In fleetctl * Similar to requests, log warnings when deprecated keys are used and rewrite the new keys internally so that they can be unmarshalled. * For `fleetctl get` and `fleetctl generate-gitops`, _only_ output the new names * The set of keys to replace is hardcoded in `fleetctl` rather than being dynamically generated as it is for API endpoints. Given the mixture of typed and untyped data and the level of nesting, dynamic map generation was very fragile and error-prone. ### Performance considerations * The biggest performance hit is the addition of the JSON key rewriter to the request pipeline. The rewriter buffers the entire request into memory before eventually passing it to the decoder than unmarshals the data into structs. I tried implementing this as a true streaming rewriter but encountered issues where the request would hang if the downstream reader (the decoder) encountered any errors. It's possible we could implement this in a streaming fashion if we replace our [current request decoder](https://github.com/fleetdm/fleet/blob/da43bf8371695382c4af0972d5da456c6b94bdaf/server/service/endpoint_utils.go#L108) with the v2 version, which is a bigger change requiring more thoughtful discussion in the engineering team. As it stands, memory usage for requests with deprecated fields will double while the request is being decoded. * The "alias rules" used to determine the old and new key names are cached per struct type and for most endpoints are generated on server start, so no performance impact is expected. * Some `fleetctl` commands may have an extra unmarshal/marshal step but as these are user-initiated and not performed in tight loops, the impact should be minimal. ### TODO * Log deprecation warnings when old URLs like "/fleet/teams" are used * Update API fields that the front-end uses to avoid deprecation warnings * Update `fleetctl apply` to accept/return `kind: fleet` rather than `kind: team` * Find/update any fleet server config vars with old language * Update all error messages that use old language # Checklist for submitter If some of the following don't apply, delete the relevant line. - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. - [X] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) ## Testing - [X] Added/updated automated tests - [X] 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 * Clicking around the front-end, no broken pages due to request ingestion errors or bad responses * Looking in network tab to verify that responses have both the old and new keys * Running `fleetctl generate-gitops` and verifying that the output looks correct and can be ingested by `fleetctl gitops` * Running `fleetctl get` and `fleetctl apply` --------- Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com>
2026-02-19 19:53:32 +00:00
`{"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(),
Deprecate "team" and "query" API params (#39873) <!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** For #39344 # Details This PR builds on the previous PR (https://github.com/fleetdm/fleet/pull/39847) which added `renameto` tags to certain API parameters to mark them as deprecated. How this is used: ### In requests * When decoding requests, log a warning if a `json` or `query` param is used that has a `renameto` tag, e.g. if a `team_id` param is sent but the related struct has `renameto:"fleet_id"` in it. * If the `renamedto` version (e.g. `fleet_id`) is sent in the request, rewrite it to the deprecated name so that it can be unmarshalled into the struct * If both versions are sent (e.g. `team_id` AND `fleet_id`), throw an error and quit * URLs with deprecated terms have new aliases using `WithAltPaths` -- warning on using old URLSs a TODO that will be handled in a subsequent PR. ### In responses * Output _both_ the deprecated and new names for fields that have `renameto` tags, so that we don't break existing workflows expecting the old keys. Uses a shared `DuplicateJSONKeys` to do the duplication. * Most API responses are handled in `EncodeCommonResponse`. Exceptions are activities, failing policy webhooks and the streaming "list hosts" endpoints which call the function directly. ### In fleetctl * Similar to requests, log warnings when deprecated keys are used and rewrite the new keys internally so that they can be unmarshalled. * For `fleetctl get` and `fleetctl generate-gitops`, _only_ output the new names * The set of keys to replace is hardcoded in `fleetctl` rather than being dynamically generated as it is for API endpoints. Given the mixture of typed and untyped data and the level of nesting, dynamic map generation was very fragile and error-prone. ### Performance considerations * The biggest performance hit is the addition of the JSON key rewriter to the request pipeline. The rewriter buffers the entire request into memory before eventually passing it to the decoder than unmarshals the data into structs. I tried implementing this as a true streaming rewriter but encountered issues where the request would hang if the downstream reader (the decoder) encountered any errors. It's possible we could implement this in a streaming fashion if we replace our [current request decoder](https://github.com/fleetdm/fleet/blob/da43bf8371695382c4af0972d5da456c6b94bdaf/server/service/endpoint_utils.go#L108) with the v2 version, which is a bigger change requiring more thoughtful discussion in the engineering team. As it stands, memory usage for requests with deprecated fields will double while the request is being decoded. * The "alias rules" used to determine the old and new key names are cached per struct type and for most endpoints are generated on server start, so no performance impact is expected. * Some `fleetctl` commands may have an extra unmarshal/marshal step but as these are user-initiated and not performed in tight loops, the impact should be minimal. ### TODO * Log deprecation warnings when old URLs like "/fleet/teams" are used * Update API fields that the front-end uses to avoid deprecation warnings * Update `fleetctl apply` to accept/return `kind: fleet` rather than `kind: team` * Find/update any fleet server config vars with old language * Update all error messages that use old language # Checklist for submitter If some of the following don't apply, delete the relevant line. - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. - [X] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) ## Testing - [X] Added/updated automated tests - [X] 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 * Clicking around the front-end, no broken pages due to request ingestion errors or bad responses * Looking in network tab to verify that responses have both the old and new keys * Running `fleetctl generate-gitops` and verifying that the output looks correct and can be ingested by `fleetctl gitops` * Running `fleetctl get` and `fleetctl apply` --------- Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com>
2026-02-19 19:53:32 +00:00
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)
Optimized api/latest/fleet/software/titles endpoint (#40458) <!-- 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 -->
2026-02-27 20:30:05 +00:00
// 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) {
Deprecate "team" and "query" API params (#39873) <!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** For #39344 # Details This PR builds on the previous PR (https://github.com/fleetdm/fleet/pull/39847) which added `renameto` tags to certain API parameters to mark them as deprecated. How this is used: ### In requests * When decoding requests, log a warning if a `json` or `query` param is used that has a `renameto` tag, e.g. if a `team_id` param is sent but the related struct has `renameto:"fleet_id"` in it. * If the `renamedto` version (e.g. `fleet_id`) is sent in the request, rewrite it to the deprecated name so that it can be unmarshalled into the struct * If both versions are sent (e.g. `team_id` AND `fleet_id`), throw an error and quit * URLs with deprecated terms have new aliases using `WithAltPaths` -- warning on using old URLSs a TODO that will be handled in a subsequent PR. ### In responses * Output _both_ the deprecated and new names for fields that have `renameto` tags, so that we don't break existing workflows expecting the old keys. Uses a shared `DuplicateJSONKeys` to do the duplication. * Most API responses are handled in `EncodeCommonResponse`. Exceptions are activities, failing policy webhooks and the streaming "list hosts" endpoints which call the function directly. ### In fleetctl * Similar to requests, log warnings when deprecated keys are used and rewrite the new keys internally so that they can be unmarshalled. * For `fleetctl get` and `fleetctl generate-gitops`, _only_ output the new names * The set of keys to replace is hardcoded in `fleetctl` rather than being dynamically generated as it is for API endpoints. Given the mixture of typed and untyped data and the level of nesting, dynamic map generation was very fragile and error-prone. ### Performance considerations * The biggest performance hit is the addition of the JSON key rewriter to the request pipeline. The rewriter buffers the entire request into memory before eventually passing it to the decoder than unmarshals the data into structs. I tried implementing this as a true streaming rewriter but encountered issues where the request would hang if the downstream reader (the decoder) encountered any errors. It's possible we could implement this in a streaming fashion if we replace our [current request decoder](https://github.com/fleetdm/fleet/blob/da43bf8371695382c4af0972d5da456c6b94bdaf/server/service/endpoint_utils.go#L108) with the v2 version, which is a bigger change requiring more thoughtful discussion in the engineering team. As it stands, memory usage for requests with deprecated fields will double while the request is being decoded. * The "alias rules" used to determine the old and new key names are cached per struct type and for most endpoints are generated on server start, so no performance impact is expected. * Some `fleetctl` commands may have an extra unmarshal/marshal step but as these are user-initiated and not performed in tight loops, the impact should be minimal. ### TODO * Log deprecation warnings when old URLs like "/fleet/teams" are used * Update API fields that the front-end uses to avoid deprecation warnings * Update `fleetctl apply` to accept/return `kind: fleet` rather than `kind: team` * Find/update any fleet server config vars with old language * Update all error messages that use old language # Checklist for submitter If some of the following don't apply, delete the relevant line. - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. - [X] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) ## Testing - [X] Added/updated automated tests - [X] 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 * Clicking around the front-end, no broken pages due to request ingestion errors or bad responses * Looking in network tab to verify that responses have both the old and new keys * Running `fleetctl generate-gitops` and verifying that the output looks correct and can be ingested by `fleetctl gitops` * Running `fleetctl get` and `fleetctl apply` --------- Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com>
2026-02-19 19:53:32 +00:00
// 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 activity_past WHERE activity_type = 'edited_setup_experience_software'`)
Deprecate "team" and "query" API params (#39873) <!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** For #39344 # Details This PR builds on the previous PR (https://github.com/fleetdm/fleet/pull/39847) which added `renameto` tags to certain API parameters to mark them as deprecated. How this is used: ### In requests * When decoding requests, log a warning if a `json` or `query` param is used that has a `renameto` tag, e.g. if a `team_id` param is sent but the related struct has `renameto:"fleet_id"` in it. * If the `renamedto` version (e.g. `fleet_id`) is sent in the request, rewrite it to the deprecated name so that it can be unmarshalled into the struct * If both versions are sent (e.g. `team_id` AND `fleet_id`), throw an error and quit * URLs with deprecated terms have new aliases using `WithAltPaths` -- warning on using old URLSs a TODO that will be handled in a subsequent PR. ### In responses * Output _both_ the deprecated and new names for fields that have `renameto` tags, so that we don't break existing workflows expecting the old keys. Uses a shared `DuplicateJSONKeys` to do the duplication. * Most API responses are handled in `EncodeCommonResponse`. Exceptions are activities, failing policy webhooks and the streaming "list hosts" endpoints which call the function directly. ### In fleetctl * Similar to requests, log warnings when deprecated keys are used and rewrite the new keys internally so that they can be unmarshalled. * For `fleetctl get` and `fleetctl generate-gitops`, _only_ output the new names * The set of keys to replace is hardcoded in `fleetctl` rather than being dynamically generated as it is for API endpoints. Given the mixture of typed and untyped data and the level of nesting, dynamic map generation was very fragile and error-prone. ### Performance considerations * The biggest performance hit is the addition of the JSON key rewriter to the request pipeline. The rewriter buffers the entire request into memory before eventually passing it to the decoder than unmarshals the data into structs. I tried implementing this as a true streaming rewriter but encountered issues where the request would hang if the downstream reader (the decoder) encountered any errors. It's possible we could implement this in a streaming fashion if we replace our [current request decoder](https://github.com/fleetdm/fleet/blob/da43bf8371695382c4af0972d5da456c6b94bdaf/server/service/endpoint_utils.go#L108) with the v2 version, which is a bigger change requiring more thoughtful discussion in the engineering team. As it stands, memory usage for requests with deprecated fields will double while the request is being decoded. * The "alias rules" used to determine the old and new key names are cached per struct type and for most endpoints are generated on server start, so no performance impact is expected. * Some `fleetctl` commands may have an extra unmarshal/marshal step but as these are user-initiated and not performed in tight loops, the impact should be minimal. ### TODO * Log deprecation warnings when old URLs like "/fleet/teams" are used * Update API fields that the front-end uses to avoid deprecation warnings * Update `fleetctl apply` to accept/return `kind: fleet` rather than `kind: team` * Find/update any fleet server config vars with old language * Update all error messages that use old language # Checklist for submitter If some of the following don't apply, delete the relevant line. - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. - [X] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) ## Testing - [X] Added/updated automated tests - [X] 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 * Clicking around the front-end, no broken pages due to request ingestion errors or bad responses * Looking in network tab to verify that responses have both the old and new keys * Running `fleetctl generate-gitops` and verifying that the output looks correct and can be ingested by `fleetctl gitops` * Running `fleetctl get` and `fleetctl apply` --------- Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com>
2026-02-19 19:53:32 +00:00
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 activity_past WHERE activity_type = 'edited_setup_experience_software'`)
require.NoError(t, err)
return nil
})
Deprecate "team" and "query" API params (#39873) <!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** For #39344 # Details This PR builds on the previous PR (https://github.com/fleetdm/fleet/pull/39847) which added `renameto` tags to certain API parameters to mark them as deprecated. How this is used: ### In requests * When decoding requests, log a warning if a `json` or `query` param is used that has a `renameto` tag, e.g. if a `team_id` param is sent but the related struct has `renameto:"fleet_id"` in it. * If the `renamedto` version (e.g. `fleet_id`) is sent in the request, rewrite it to the deprecated name so that it can be unmarshalled into the struct * If both versions are sent (e.g. `team_id` AND `fleet_id`), throw an error and quit * URLs with deprecated terms have new aliases using `WithAltPaths` -- warning on using old URLSs a TODO that will be handled in a subsequent PR. ### In responses * Output _both_ the deprecated and new names for fields that have `renameto` tags, so that we don't break existing workflows expecting the old keys. Uses a shared `DuplicateJSONKeys` to do the duplication. * Most API responses are handled in `EncodeCommonResponse`. Exceptions are activities, failing policy webhooks and the streaming "list hosts" endpoints which call the function directly. ### In fleetctl * Similar to requests, log warnings when deprecated keys are used and rewrite the new keys internally so that they can be unmarshalled. * For `fleetctl get` and `fleetctl generate-gitops`, _only_ output the new names * The set of keys to replace is hardcoded in `fleetctl` rather than being dynamically generated as it is for API endpoints. Given the mixture of typed and untyped data and the level of nesting, dynamic map generation was very fragile and error-prone. ### Performance considerations * The biggest performance hit is the addition of the JSON key rewriter to the request pipeline. The rewriter buffers the entire request into memory before eventually passing it to the decoder than unmarshals the data into structs. I tried implementing this as a true streaming rewriter but encountered issues where the request would hang if the downstream reader (the decoder) encountered any errors. It's possible we could implement this in a streaming fashion if we replace our [current request decoder](https://github.com/fleetdm/fleet/blob/da43bf8371695382c4af0972d5da456c6b94bdaf/server/service/endpoint_utils.go#L108) with the v2 version, which is a bigger change requiring more thoughtful discussion in the engineering team. As it stands, memory usage for requests with deprecated fields will double while the request is being decoded. * The "alias rules" used to determine the old and new key names are cached per struct type and for most endpoints are generated on server start, so no performance impact is expected. * Some `fleetctl` commands may have an extra unmarshal/marshal step but as these are user-initiated and not performed in tight loops, the impact should be minimal. ### TODO * Log deprecation warnings when old URLs like "/fleet/teams" are used * Update API fields that the front-end uses to avoid deprecation warnings * Update `fleetctl apply` to accept/return `kind: fleet` rather than `kind: team` * Find/update any fleet server config vars with old language * Update all error messages that use old language # Checklist for submitter If some of the following don't apply, delete the relevant line. - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. - [X] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) ## Testing - [X] Added/updated automated tests - [X] 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 * Clicking around the front-end, no broken pages due to request ingestion errors or bad responses * Looking in network tab to verify that responses have both the old and new keys * Running `fleetctl generate-gitops` and verifying that the output looks correct and can be ingested by `fleetctl gitops` * Running `fleetctl get` and `fleetctl apply` --------- Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com>
2026-02-19 19:53:32 +00:00
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)
}
func (s *integrationMDMTestSuite) TestAndroidWebApps() {
ctx := context.Background()
t := s.T()
s.setSkipWorkerJobs(t)
appConf, err := s.ds.AppConfig(ctx)
require.NoError(t, err)
appConf.MDM.AndroidEnabledAndConfigured = false
err = s.ds.SaveAppConfig(ctx, appConf)
require.NoError(t, err)
// attempt to create a webapp before android MDM is enabled
body, headers := generateMultipartRequest(t, "", "", nil, s.token, map[string][]string{
"title": {"Web App"},
"url": {"https://example.com"},
})
res := s.DoRawWithHeaders("POST", "/api/latest/fleet/software/web_apps", body.Bytes(), http.StatusBadRequest, headers)
require.Contains(t, extractServerErrorText(res.Body), "Android MDM isn't turned on.")
enterpriseID := s.enableAndroidMDM(t)
s.androidAPIClient.EnterprisesWebAppsCreateFunc = func(ctx context.Context, enterpriseName string, app *androidmanagement.WebApp) (*androidmanagement.WebApp, error) {
id := uuid.NewString()
return &androidmanagement.WebApp{Name: fmt.Sprintf("enterprises/%s/webApps/%s", enterpriseID, id)}, nil
}
cases := []struct {
desc string
title string
url string
iconFile string // filename in testdata/icons/
wantStatus int
wantErrMsg string
}{
{
desc: "missing title",
title: "",
url: "http://example.com",
wantStatus: http.StatusBadRequest,
wantErrMsg: "title multipart field is required",
},
{
desc: "missing url",
title: "WebApp",
url: "",
wantStatus: http.StatusBadRequest,
wantErrMsg: "url multipart field is required",
},
{
desc: "invalid url",
title: "WebApp",
url: "non-absolute-url",
wantStatus: http.StatusBadRequest,
wantErrMsg: "The start URL must be a valid absolute URL.",
},
{
desc: "valid without icon",
title: "WebApp",
url: "http://example.com",
wantStatus: http.StatusOK,
wantErrMsg: "",
},
{
desc: "invalid icon not a png",
title: "WebApp",
url: "http://example.com",
iconFile: "not-a-png.txt",
wantStatus: http.StatusBadRequest,
wantErrMsg: "The icon must be a PNG file and square, with dimensions of at least 512 x 512px.",
},
{
desc: "invalid icon not square",
title: "WebApp",
url: "http://example.com",
iconFile: "non-square.png",
wantStatus: http.StatusBadRequest,
wantErrMsg: "The icon must be a PNG file and square, with dimensions of at least 512 x 512px.",
},
{
desc: "invalid icon too small",
title: "WebApp",
url: "http://example.com",
iconFile: "200px-square.png",
wantStatus: http.StatusBadRequest,
wantErrMsg: "The icon must be a PNG file and square, with dimensions of at least 512 x 512px.",
},
{
desc: "valid with icon",
title: "WebApp",
url: "http://example.com",
iconFile: "512px-square.png",
wantStatus: http.StatusOK,
wantErrMsg: "",
},
}
for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
var filename string
var iconData []byte
if c.iconFile != "" {
filename = c.iconFile
b, err := os.ReadFile(filepath.Join("testdata", "icons", c.iconFile))
require.NoError(t, err)
iconData = b
}
body, headers := generateMultipartRequest(t, "icon", filename, iconData, s.token, map[string][]string{
"title": {c.title},
"url": {c.url},
})
res := s.DoRawWithHeaders("POST", "/api/latest/fleet/software/web_apps", body.Bytes(), c.wantStatus, headers)
if c.wantErrMsg != "" {
require.Contains(t, extractServerErrorText(res.Body), c.wantErrMsg)
} else {
var resp createAndroidWebAppResponse
err := json.NewDecoder(res.Body).Decode(&resp)
require.NoError(t, err)
require.NotEmpty(t, resp.AppStoreID)
}
})
}
}
func (s *integrationMDMTestSuite) TestAndroidWebAppsCannotSetConfiguration() {
ctx := context.Background()
t := s.T()
s.setSkipWorkerJobs(t)
appConf, err := s.ds.AppConfig(ctx)
require.NoError(t, err)
appConf.MDM.AndroidEnabledAndConfigured = false
err = s.ds.SaveAppConfig(ctx, appConf)
require.NoError(t, err)
enterpriseID := s.enableAndroidMDM(t)
var count int
s.androidAPIClient.EnterprisesWebAppsCreateFunc = func(ctx context.Context, enterpriseName string, app *androidmanagement.WebApp) (*androidmanagement.WebApp, error) {
count++
id := "abc" + fmt.Sprint(count)
return &androidmanagement.WebApp{Name: fmt.Sprintf("enterprises/%s/webApps/com.google.enterprise.webapp.%s", enterpriseID, id)}, nil
}
s.androidAPIClient.EnterprisesApplicationsFunc = func(ctx context.Context, enterpriseName string, packageName string) (*androidmanagement.Application, error) {
ix := strings.LastIndex(packageName, ".") // title is the final segment
return &androidmanagement.Application{IconUrl: "https://example.com/1.jpg", Title: packageName[ix+1:]}, nil
}
// create a webapp
body, headers := generateMultipartRequest(t, "", "", nil, s.token, map[string][]string{
"title": {"Web App"},
"url": {"https://example.com"},
})
var resp createAndroidWebAppResponse
res := s.DoRawWithHeaders("POST", "/api/latest/fleet/software/web_apps", body.Bytes(), http.StatusOK, headers)
err = json.NewDecoder(res.Body).Decode(&resp)
require.NoError(t, err)
webAppID := resp.AppStoreID
// add it to Fleet with configuration, will fail
res = s.Do("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
AppStoreID: webAppID, Platform: fleet.AndroidPlatform, Configuration: json.RawMessage(`{"key":"value"}`),
}, http.StatusUnprocessableEntity)
require.Contains(t, extractServerErrorText(res.Body), "Android web apps don't support configurations.")
// add it without configuration, will work
var addResp addAppStoreAppResponse
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
AppStoreID: webAppID, Platform: fleet.AndroidPlatform,
}, http.StatusOK, &addResp)
webAppTitleID := addResp.TitleID
// update it with configuration, will fail
res = s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", webAppTitleID),
&updateAppStoreAppRequest{Configuration: json.RawMessage(`{"key":"value"}`)},
http.StatusUnprocessableEntity)
require.Contains(t, extractServerErrorText(res.Body), "Android web apps don't support configurations.")
// update it without configuration, will work
var updateResp updateAppStoreAppResponse
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", webAppTitleID),
&updateAppStoreAppRequest{DisplayName: ptr.String("MyWebApp")},
http.StatusOK, &updateResp)
require.Equal(t, webAppID, updateResp.AppStoreApp.AdamID)
require.Equal(t, "MyWebApp", updateResp.AppStoreApp.DisplayName)
require.Equal(t, "abc1", updateResp.AppStoreApp.Name)
require.True(t, updateResp.AppStoreApp.SelfService)
require.Nil(t, updateResp.AppStoreApp.Configuration)
// batch-set with configuration, will fail
res = s.Do("POST", "/api/latest/fleet/software/app_store_apps/batch",
batchAssociateAppStoreAppsRequest{
DryRun: false,
Apps: []fleet.VPPBatchPayload{
{AppStoreID: webAppID, SelfService: true, Platform: fleet.AndroidPlatform, Configuration: json.RawMessage(`{"key":"value"}`)},
},
}, http.StatusUnprocessableEntity)
require.Contains(t, extractServerErrorText(res.Body), "Android web apps don't support configurations.")
// batch-set with multiple Android apps, with configuration on the webApp, will fail
res = s.Do("POST", "/api/latest/fleet/software/app_store_apps/batch",
batchAssociateAppStoreAppsRequest{
DryRun: false,
Apps: []fleet.VPPBatchPayload{
{AppStoreID: webAppID, SelfService: true, Platform: fleet.AndroidPlatform, Configuration: json.RawMessage(`{"key":"value"}`)},
{AppStoreID: "com.google.chrome", SelfService: true, Platform: fleet.AndroidPlatform},
},
}, http.StatusUnprocessableEntity)
require.Contains(t, extractServerErrorText(res.Body), "Android web apps don't support configurations.")
// batch-set without configuration, will work
var batchResp batchAssociateAppStoreAppsResponse
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps/batch",
batchAssociateAppStoreAppsRequest{
DryRun: false,
Apps: []fleet.VPPBatchPayload{
{AppStoreID: webAppID, SelfService: true, Platform: fleet.AndroidPlatform},
{AppStoreID: "com.google.chrome", SelfService: true, Platform: fleet.AndroidPlatform},
},
}, http.StatusOK, &batchResp)
require.Len(t, batchResp.Apps, 2)
}
func (s *integrationMDMTestSuite) TestAndroidWebAppsDuplicateName() {
ctx := context.Background()
t := s.T()
s.setSkipWorkerJobs(t)
appConf, err := s.ds.AppConfig(ctx)
require.NoError(t, err)
appConf.MDM.AndroidEnabledAndConfigured = false
err = s.ds.SaveAppConfig(ctx, appConf)
require.NoError(t, err)
enterpriseID := s.enableAndroidMDM(t)
var count int
s.androidAPIClient.EnterprisesWebAppsCreateFunc = func(ctx context.Context, enterpriseName string, app *androidmanagement.WebApp) (*androidmanagement.WebApp, error) {
count++
id := "dup" + fmt.Sprint(count)
return &androidmanagement.WebApp{Name: fmt.Sprintf("enterprises/%s/webApps/com.google.enterprise.webapp.%s", enterpriseID, id)}, nil
}
s.androidAPIClient.EnterprisesApplicationsFunc = func(ctx context.Context, enterpriseName string, packageName string) (*androidmanagement.Application, error) {
return &androidmanagement.Application{IconUrl: "https://example.com/icon.jpg", Title: "Duplicate Web App"}, nil
}
// create two web apps with the same title (this is fine — POST /web_apps is just a Google API wrapper)
body, headers := generateMultipartRequest(t, "", "", nil, s.token, map[string][]string{
"title": {"Duplicate Web App"},
"url": {"https://example.com"},
})
var resp1 createAndroidWebAppResponse
res := s.DoRawWithHeaders("POST", "/api/latest/fleet/software/web_apps", body.Bytes(), http.StatusOK, headers)
err = json.NewDecoder(res.Body).Decode(&resp1)
require.NoError(t, err)
webAppID1 := resp1.AppStoreID
body, headers = generateMultipartRequest(t, "", "", nil, s.token, map[string][]string{
"title": {"Duplicate Web App"},
"url": {"https://different-url.com"},
})
var resp2 createAndroidWebAppResponse
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/software/web_apps", body.Bytes(), http.StatusOK, headers)
err = json.NewDecoder(res.Body).Decode(&resp2)
require.NoError(t, err)
webAppID2 := resp2.AppStoreID
// create a team to add the apps to
tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name()})
require.NoError(t, err)
// add the first web app to the team
var addResp addAppStoreAppResponse
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
AppStoreID: webAppID1, Platform: fleet.AndroidPlatform, TeamID: &tm.ID,
}, http.StatusOK, &addResp)
// add the second web app (same name) to the same team
res = s.Do("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
AppStoreID: webAppID2, Platform: fleet.AndroidPlatform, TeamID: &tm.ID,
}, http.StatusConflict)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, `Couldn't add.`)
require.Contains(t, errMsg, `"Duplicate Web App"`)
require.Contains(t, errMsg, "already exists in this fleet")
// add the second web app (same name, rejected from team 1) to a different team
tm2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "2"})
require.NoError(t, err)
var addResp2 addAppStoreAppResponse
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
AppStoreID: webAppID2, Platform: fleet.AndroidPlatform, TeamID: &tm2.ID,
}, http.StatusOK, &addResp2)
}