From 8d63bf2bbe1fc9de75b96b7fcf6ebede0a2f06df Mon Sep 17 00:00:00 2001 From: Carlo <1778532+cdcme@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:35:28 -0400 Subject: [PATCH] Prevent duplicate Android web-clip apps with the same name (#42664) Fixes #42641. --- ee/server/service/vpp.go | 10 +++ server/datastore/mysql/vpp.go | 11 ++++ server/fleet/datastore.go | 4 ++ server/mock/datastore_mock.go | 12 ++++ .../integration_android_software_test.go | 62 +++++++++++++++++++ server/service/vpp_test.go | 3 + 6 files changed, 102 insertions(+) diff --git a/ee/server/service/vpp.go b/ee/server/service/vpp.go index c26f29dda4..98c1d8c4bb 100644 --- a/ee/server/service/vpp.go +++ b/ee/server/service/vpp.go @@ -1300,6 +1300,16 @@ func (svc *Service) CreateAndroidWebApp(ctx context.Context, title, startURL str } } + exists, err := svc.ds.CheckAndroidWebAppNameExists(ctx, title) + if err != nil { + return "", ctxerr.Wrap(ctx, err, "checking android web app name") + } + if exists { + return "", fleet.ConflictError{ + Message: fmt.Sprintf(`Couldn't add. Web app with this name ("%s") already exists in this fleet. Please add a web app with a different name or delete the existing app and try again.`, title), + } + } + enterprise, err := svc.ds.GetEnterprise(ctx) if err != nil { return "", ctxerr.Wrap(ctx, err, "get android enterprise") diff --git a/server/datastore/mysql/vpp.go b/server/datastore/mysql/vpp.go index ae47e20c82..daae1dbab0 100644 --- a/server/datastore/mysql/vpp.go +++ b/server/datastore/mysql/vpp.go @@ -2743,3 +2743,14 @@ ORDER BY } return nil } + +func (ds *Datastore) CheckAndroidWebAppNameExists(ctx context.Context, name string) (bool, error) { + var exists bool + err := sqlx.GetContext(ctx, ds.reader(ctx), &exists, + `SELECT EXISTS(SELECT 1 FROM vpp_apps WHERE name = ? AND adam_id LIKE ? AND platform = 'android')`, + name, fleet.AndroidWebAppPrefix+"%") + if err != nil { + return false, ctxerr.Wrap(ctx, err, "checking android web app name exists") + } + return exists, nil +} diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 49ff7ee2b3..eb39b149a0 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -760,6 +760,10 @@ type Datastore interface { CheckConflictingInstallerExists(ctx context.Context, teamID *uint, bundleIdentifier, platform string) (bool, error) CheckConflictingInHouseAppExists(ctx context.Context, teamID *uint, bundleIdentifier, platform string) (bool, error) + // CheckAndroidWebAppNameExists checks if an Android web app with the given + // name already exists in the vpp_apps table (fleet-wide). + CheckAndroidWebAppNameExists(ctx context.Context, name string) (bool, error) + /////////////////////////////////////////////////////////////////////////////// // OperatingSystemsStore diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index e5a2520f7a..e254eb611c 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -577,6 +577,8 @@ type CheckConflictingInstallerExistsFunc func(ctx context.Context, teamID *uint, type CheckConflictingInHouseAppExistsFunc func(ctx context.Context, teamID *uint, bundleIdentifier string, platform string) (bool, error) +type CheckAndroidWebAppNameExistsFunc func(ctx context.Context, name string) (bool, error) + type GetHostOperatingSystemFunc func(ctx context.Context, hostID uint) (*fleet.OperatingSystem, error) type ListOperatingSystemsFunc func(ctx context.Context) ([]fleet.OperatingSystem, error) @@ -2671,6 +2673,9 @@ type DataStore struct { CheckConflictingInHouseAppExistsFunc CheckConflictingInHouseAppExistsFunc CheckConflictingInHouseAppExistsFuncInvoked bool + CheckAndroidWebAppNameExistsFunc CheckAndroidWebAppNameExistsFunc + CheckAndroidWebAppNameExistsFuncInvoked bool + GetHostOperatingSystemFunc GetHostOperatingSystemFunc GetHostOperatingSystemFuncInvoked bool @@ -6506,6 +6511,13 @@ func (s *DataStore) CheckConflictingInHouseAppExists(ctx context.Context, teamID return s.CheckConflictingInHouseAppExistsFunc(ctx, teamID, bundleIdentifier, platform) } +func (s *DataStore) CheckAndroidWebAppNameExists(ctx context.Context, name string) (bool, error) { + s.mu.Lock() + s.CheckAndroidWebAppNameExistsFuncInvoked = true + s.mu.Unlock() + return s.CheckAndroidWebAppNameExistsFunc(ctx, name) +} + func (s *DataStore) GetHostOperatingSystem(ctx context.Context, hostID uint) (*fleet.OperatingSystem, error) { s.mu.Lock() s.GetHostOperatingSystemFuncInvoked = true diff --git a/server/service/integration_android_software_test.go b/server/service/integration_android_software_test.go index 1340458dfe..314bfc961b 100644 --- a/server/service/integration_android_software_test.go +++ b/server/service/integration_android_software_test.go @@ -1329,3 +1329,65 @@ func (s *integrationMDMTestSuite) TestAndroidWebAppsCannotSetConfiguration() { }, 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 a web app + body, headers := generateMultipartRequest(t, "", "", nil, s.token, map[string][]string{ + "title": {"Duplicate 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 (populates vpp_apps) + var addResp addAppStoreAppResponse + s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{ + AppStoreID: webAppID, Platform: fleet.AndroidPlatform, + }, http.StatusOK, &addResp) + + // create another web app with the same name + body, headers = generateMultipartRequest(t, "", "", nil, s.token, map[string][]string{ + "title": {"Duplicate Web App"}, + "url": {"https://different-url.com"}, + }) + res = s.DoRawWithHeaders("POST", "/api/latest/fleet/software/web_apps", body.Bytes(), http.StatusConflict, headers) + 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") + + // create a web app with a different name + body, headers = generateMultipartRequest(t, "", "", nil, s.token, map[string][]string{ + "title": {"Different Web App"}, + "url": {"https://example.com"}, + }) + res = s.DoRawWithHeaders("POST", "/api/latest/fleet/software/web_apps", body.Bytes(), http.StatusOK, headers) + var resp2 createAndroidWebAppResponse + err = json.NewDecoder(res.Body).Decode(&resp2) + require.NoError(t, err) + require.NotEmpty(t, resp2.AppStoreID) +} diff --git a/server/service/vpp_test.go b/server/service/vpp_test.go index c62e250187..feeb2a700d 100644 --- a/server/service/vpp_test.go +++ b/server/service/vpp_test.go @@ -117,6 +117,9 @@ func TestVPPAuth(t *testing.T) { ds.GetEnterpriseFunc = func(ctx context.Context) (*android.Enterprise, error) { return &android.Enterprise{}, nil } + ds.CheckAndroidWebAppNameExistsFunc = func(ctx context.Context, name string) (bool, error) { + return false, nil + } // Note: these calls always return an error because they're attempting to unmarshal a // non-existent VPP token.