From c0fc3787fcb69bf576e3a665afa6ca35e2714331 Mon Sep 17 00:00:00 2001 From: Carlo <1778532+cdcme@users.noreply.github.com> Date: Tue, 17 Mar 2026 06:55:22 -0400 Subject: [PATCH] Fix icon upload for multi-team software titles (#41785) Fixes #41688 --------- Co-authored-by: Ian Littman --- changes/41688-fix-icons-via-gitops | 1 + .../EditIconModal/EditIconModal.tsx | 11 ++- .../datastore/mysql/software_title_icons.go | 6 +- .../mysql/software_title_icons_test.go | 84 ++++++++++++++++++- 4 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 changes/41688-fix-icons-via-gitops diff --git a/changes/41688-fix-icons-via-gitops b/changes/41688-fix-icons-via-gitops new file mode 100644 index 0000000000..543f55e062 --- /dev/null +++ b/changes/41688-fix-icons-via-gitops @@ -0,0 +1 @@ +Fixed custom app icons not getting set via GitOps when the same software title exists in multiple teams. diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditIconModal/EditIconModal.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditIconModal/EditIconModal.tsx index 942dfead5a..2707186866 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditIconModal/EditIconModal.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditIconModal/EditIconModal.tsx @@ -245,7 +245,7 @@ const EditIconModal = ({ }); }; - const { data: customIconData } = useQuery( + const { data: customIconData, isError: isCustomIconError } = useQuery( ["softwareIcon", softwareId, teamIdForApi, iconUploadedAt], () => softwareAPI.getSoftwareIcon(softwareId, teamIdForApi), { @@ -336,6 +336,13 @@ const EditIconModal = ({ // useQuery does not handle dimension extraction, so this is required for updating // state with image details after loading the icon blob in the browser useEffect(() => { + // If the icon fetch failed, stop showing the spinner and fall back + if (isCustomIconError && isFirstLoadWithCustomIcon) { + setIsFirstLoadWithCustomIcon(false); + resetIconState(); + return; + } + // Handle API custom icon blob conversion and initialization if ( shouldFetchCustomIcon && @@ -375,6 +382,8 @@ const EditIconModal = ({ } }, [ customIconData, + isCustomIconError, + isFirstLoadWithCustomIcon, iconState.status, shouldFetchCustomIcon, iconState.previewUrl, diff --git a/server/datastore/mysql/software_title_icons.go b/server/datastore/mysql/software_title_icons.go index c2b4f3d910..172882841c 100644 --- a/server/datastore/mysql/software_title_icons.go +++ b/server/datastore/mysql/software_title_icons.go @@ -161,9 +161,13 @@ func (ds *Datastore) ActivityDetailsForSoftwareTitleIcon(ctx context.Context, te INNER JOIN software_titles ON software_title_icons.software_title_id = software_titles.id LEFT JOIN teams ON software_title_icons.team_id = teams.id LEFT JOIN software_installers ON software_installers.title_id = software_titles.id + AND software_installers.global_or_team_id = software_title_icons.team_id LEFT JOIN in_house_apps ON in_house_apps.title_id = software_titles.id + AND in_house_apps.global_or_team_id = software_title_icons.team_id LEFT JOIN vpp_apps ON vpp_apps.title_id = software_titles.id - LEFT JOIN vpp_apps_teams ON vpp_apps_teams.adam_id = vpp_apps.adam_id AND vpp_apps_teams.platform = vpp_apps.platform + LEFT JOIN vpp_apps_teams ON vpp_apps_teams.adam_id = vpp_apps.adam_id + AND vpp_apps_teams.platform = vpp_apps.platform + AND vpp_apps_teams.global_or_team_id = software_title_icons.team_id WHERE software_title_icons.team_id = ? AND software_title_icons.software_title_id = ? ` err := sqlx.GetContext(ctx, ds.reader(ctx), &details, query, teamID, titleID) diff --git a/server/datastore/mysql/software_title_icons_test.go b/server/datastore/mysql/software_title_icons_test.go index 9fff424df8..774dabd7f3 100644 --- a/server/datastore/mysql/software_title_icons_test.go +++ b/server/datastore/mysql/software_title_icons_test.go @@ -256,7 +256,7 @@ func testDeleteSoftwareTitleIcon(t *testing.T, ds *Datastore) { func testActivityDetailsForSoftwareTitleIcon(t *testing.T, ds *Datastore) { ctx := context.Background() - var teamID, titleID, installerID uint + var teamID, titleID, installerID, installer2ID uint var err error testCases := []struct { name string @@ -481,6 +481,88 @@ func testActivityDetailsForSoftwareTitleIcon(t *testing.T, ds *Datastore) { require.Nil(t, activity.LabelsExcludeAny) require.Nil(t, activity.LabelsIncludeAny) }}, + {"multi-team software installer", func(ds *Datastore) { + // Create two teams sharing the same software title with installers in both teams. + // This verifies that the query returns exactly one row per team instead of N rows. + user := test.NewUser(t, ds, "user1", "user1@example.com", false) + teamID, titleID, err = createTeamAndSoftwareTitle(t, ctx, ds) + require.NoError(t, err) + + // Create a second team + tm2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"}) + require.NoError(t, err) + + // Create installer in team 1 + tfr1, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir) + require.NoError(t, err) + installerID, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "hello", + InstallerFile: tfr1, + StorageID: "storage1", + Filename: "foo.pkg", + Title: "foo", + Version: "0.0.3", + Source: "apps", + TeamID: &teamID, + UserID: user.ID, + BundleIdentifier: "foo.bundle.id", + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + }) + require.NoError(t, err) + + // Create installer in team 2 for the same title + tfr2, err := fleet.NewTempFileReader(strings.NewReader("world"), t.TempDir) + require.NoError(t, err) + installer2ID, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "world", + InstallerFile: tfr2, + StorageID: "storage2", + Filename: "foo.pkg", + Title: "foo", + Version: "0.0.3", + Source: "apps", + TeamID: &tm2.ID, + UserID: user.ID, + BundleIdentifier: "foo.bundle.id", + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + }) + require.NoError(t, err) + + // Create icons in both teams + _, err = ds.CreateOrUpdateSoftwareTitleIcon(ctx, &fleet.UploadSoftwareTitleIconPayload{ + TeamID: teamID, + TitleID: titleID, + StorageID: "icon-storage-1", + Filename: "icon1.png", + }) + require.NoError(t, err) + _, err = ds.CreateOrUpdateSoftwareTitleIcon(ctx, &fleet.UploadSoftwareTitleIconPayload{ + TeamID: tm2.ID, + TitleID: titleID, + StorageID: "icon-storage-2", + Filename: "icon2.png", + }) + require.NoError(t, err) + }, func(t *testing.T, ds *Datastore) { + // Query for team 1 should return exactly one row with team 1's installer + activity1, err := ds.ActivityDetailsForSoftwareTitleIcon(ctx, teamID, titleID) + require.NoError(t, err) + require.Equal(t, installerID, *activity1.SoftwareInstallerID) + require.Equal(t, "team1", *activity1.TeamName) + require.Equal(t, teamID, activity1.TeamID) + + // Query for team 2 should also succeed (not produce multiple rows) + tm2, err := ds.TeamByName(ctx, "team2") + require.NoError(t, err) + require.NotZero(t, tm2.ID) + + activity2, err := ds.ActivityDetailsForSoftwareTitleIcon(ctx, tm2.ID, titleID) + require.NoError(t, err) + require.NotNil(t, activity2.SoftwareInstallerID) + require.Equal(t, installer2ID, *activity2.SoftwareInstallerID) + require.Equal(t, "team2", *activity2.TeamName) + require.Equal(t, tm2.ID, activity2.TeamID) + }}, {"in house app", func(ds *Datastore) { user := test.NewUser(t, ds, "user1", "user1@example.com", false) teamID, titleID, err = createTeamAndSoftwareTitle(t, ctx, ds)