Support custom icons for in-house apps (#35161)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #35138 

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [ ] 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.

- [ ] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements)
- [ ] If paths of existing endpoints are modified without backwards
compatibility, checked the frontend/CLI for any necessary changes

## Testing

- [X] Added/updated automated tests
- [ ] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)

- [X] QA'd all new/changed functionality manually
This commit is contained in:
Jonathan Katz
2025-11-04 13:34:25 -05:00
committed by GitHub
parent ebfacd3f36
commit cfd54cf090
5 changed files with 173 additions and 6 deletions
+28 -3
View File
@@ -51,6 +51,7 @@ func (svc *Service) UploadSoftwareTitleIcon(ctx context.Context, payload *fleet.
}
var softwareInstaller *fleet.SoftwareInstaller
var vppApp *fleet.VPPAppStoreApp
var inHouseApp *fleet.SoftwareInstaller
vc, ok := viewer.FromContext(ctx)
if !ok {
@@ -67,9 +68,15 @@ func (svc *Service) UploadSoftwareTitleIcon(ctx context.Context, payload *fleet.
if err != nil && !fleet.IsNotFound(err) {
return fleet.SoftwareTitleIcon{}, ctxerr.Wrap(ctx, err, "getting VPP app")
}
if vppApp == nil {
inHouseApp, err = svc.ds.GetInHouseAppMetadataByTeamAndTitleID(ctx, &payload.TeamID, payload.TitleID)
if err != nil && !fleet.IsNotFound(err) {
return fleet.SoftwareTitleIcon{}, ctxerr.Wrap(ctx, err, "getting in-house app")
}
}
}
if softwareInstaller == nil && vppApp == nil {
return fleet.SoftwareTitleIcon{}, &fleet.BadRequestError{Message: fmt.Sprintf("Software title has no software installer or VPP app: %d", payload.TitleID)}
if softwareInstaller == nil && vppApp == nil && inHouseApp == nil {
return fleet.SoftwareTitleIcon{}, &fleet.BadRequestError{Message: fmt.Sprintf("Software title has no software installer, VPP app, or in-house app: %d", payload.TitleID)}
}
icon, err := svc.ds.GetSoftwareTitleIcon(ctx, payload.TeamID, payload.TitleID)
@@ -228,5 +235,23 @@ func generateEditActivityForSoftwareTitleIcon(ctx context.Context, svc *Service,
return nil
}
return ctxerr.New(ctx, "no software installer or VPP app found for software title icon")
if activityDetailsForSoftwareTitleIcon.InHouseAppID != nil {
if err := svc.NewActivity(ctx, user, fleet.ActivityTypeEditedSoftware{
SoftwareTitle: activityDetailsForSoftwareTitleIcon.SoftwareTitle,
SoftwarePackage: activityDetailsForSoftwareTitleIcon.Filename,
TeamName: activityDetailsForSoftwareTitleIcon.TeamName,
TeamID: &activityDetailsForSoftwareTitleIcon.TeamID,
SelfService: activityDetailsForSoftwareTitleIcon.SelfService,
SoftwareIconURL: &iconUrl,
LabelsIncludeAny: activityDetailsForSoftwareTitleIcon.LabelsIncludeAny,
LabelsExcludeAny: activityDetailsForSoftwareTitleIcon.LabelsExcludeAny,
SoftwareTitleID: activityDetailsForSoftwareTitleIcon.SoftwareTitleID,
}); err != nil {
return ctxerr.Wrap(ctx, err, "creating activity for software title icon")
}
return nil
}
return ctxerr.New(ctx, "no software installer, VPP app, or in-house app found for software title icon")
}
+18 -1
View File
@@ -148,6 +148,7 @@ func (ds *Datastore) ActivityDetailsForSoftwareTitleIcon(ctx context.Context, te
query := `
SELECT
software_installers.id AS software_installer_id,
in_house_apps.id AS in_house_app_id,
vpp_apps.adam_id AS adam_id,
vpp_apps_teams.id AS vpp_app_team_id,
vpp_apps.icon_url AS vpp_icon_url,
@@ -155,13 +156,14 @@ func (ds *Datastore) ActivityDetailsForSoftwareTitleIcon(ctx context.Context, te
software_installers.filename AS filename,
teams.name AS team_name,
COALESCE(teams.id, 0) AS team_id,
COALESCE(software_installers.self_service, vpp_apps_teams.self_service) AS self_service,
COALESCE(software_installers.self_service, vpp_apps_teams.self_service, false) AS self_service, -- TODO(JK): change false to iha.self_service once that is merged
software_titles.id AS software_title_id,
vpp_apps.platform AS platform
FROM software_title_icons
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
LEFT JOIN in_house_apps ON in_house_apps.title_id = software_titles.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
WHERE software_title_icons.team_id = ? AND software_title_icons.software_title_id = ?
@@ -205,6 +207,21 @@ func (ds *Datastore) ActivityDetailsForSoftwareTitleIcon(ctx context.Context, te
return fleet.DetailsForSoftwareIconActivity{}, ctxerr.Wrap(ctx, err, "getting labels for software title icon")
}
}
if details.InHouseAppID != nil {
labelQuery := `
SELECT
labels.id AS id,
labels.name AS name,
in_house_app_labels.exclude AS exclude
FROM in_house_app_labels
INNER JOIN labels ON in_house_app_labels.label_id = labels.id
WHERE in_house_app_id = ?
`
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &labels, labelQuery, details.InHouseAppID); err != nil {
return fleet.DetailsForSoftwareIconActivity{}, ctxerr.Wrap(ctx, err, "getting labels for software title icon")
}
}
for _, l := range labels {
if l.Exclude {
details.LabelsExcludeAny = append(details.LabelsExcludeAny, fleet.ActivitySoftwareLabel{
@@ -669,6 +669,75 @@ func testDeleteIconsAssociatedWithTitlesWithoutInstallers(t *testing.T, ds *Data
err = ds.DeleteIconsAssociatedWithTitlesWithoutInstallers(ctx, 1)
require.NoError(t, err)
err = ds.writer(ctx).Select(&titleIds, `SELECT software_title_id FROM software_title_icons where team_id = ?`, teamID)
require.NoError(t, err)
require.Len(t, titleIds, 1)
require.Contains(t, titleIds, titleID)
},
},
{
name: "does not delete icons still associated with an in-house app",
before: func(ds *Datastore) {
user := test.NewUser(t, ds, "user1", "user1@example.com", false)
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 1"})
require.NoError(t, err)
// create an in-house app that will create two titles
payload := fleet.UploadSoftwareInstallerPayload{
TeamID: &team.ID,
UserID: user.ID,
Title: "foo",
Filename: "foo.ipa",
BundleIdentifier: "foo.bundle.id",
StorageID: "testingtesting123",
Platform: "ios",
Extension: "ipa",
Version: "1.2.3",
ValidatedLabels: &fleet.LabelIdentsWithScope{},
}
_, titleID, err = ds.MatchOrCreateSoftwareInstaller(ctx, &payload)
require.NoError(t, err)
var softwareInstallerTitleIds []struct {
ID uint `db:"title_id"`
}
err = ds.writer(ctx).Select(&softwareInstallerTitleIds, `SELECT title_id from in_house_apps`)
require.NoError(t, err)
require.Len(t, softwareInstallerTitleIds, 2) // iha create 2 titles
require.Equal(t, titleID, softwareInstallerTitleIds[1].ID)
_, err = ds.CreateOrUpdateSoftwareTitleIcon(ctx, &fleet.UploadSoftwareTitleIconPayload{
TeamID: team.ID,
TitleID: titleID,
StorageID: "storage-id-1",
Filename: "test-icon-updated.png",
})
require.NoError(t, err)
result, err := ds.writer(ctx).ExecContext(ctx, `
INSERT INTO software_titles (name, source, bundle_identifier) VALUES (?, ?, ?)
`, "foo2", "ios_apps", "foo2.bundle.id")
require.NoError(t, err)
deletedTitleID64, err := result.LastInsertId()
deletedTitleID = uint(deletedTitleID64) //nolint:gosec
require.NoError(t, err)
_, err = ds.CreateOrUpdateSoftwareTitleIcon(ctx, &fleet.UploadSoftwareTitleIconPayload{
TeamID: team.ID,
TitleID: deletedTitleID,
StorageID: "storage-id-1",
Filename: "test-icon-updated.png",
})
require.NoError(t, err)
},
testFunc: func(t *testing.T, ds *Datastore) {
var titleIds []uint
err = ds.writer(ctx).Select(&titleIds, `SELECT software_title_id FROM software_title_icons where team_id = ?`, teamID)
require.NoError(t, err)
require.Contains(t, titleIds, titleID)
require.Contains(t, titleIds, deletedTitleID)
err = ds.DeleteIconsAssociatedWithTitlesWithoutInstallers(ctx, 1)
require.NoError(t, err)
err = ds.writer(ctx).Select(&titleIds, `SELECT software_title_id FROM software_title_icons where team_id = ?`, teamID)
require.NoError(t, err)
require.Len(t, titleIds, 1)
+1
View File
@@ -47,6 +47,7 @@ type SoftwareTitleIconStore interface {
type DetailsForSoftwareIconActivity struct {
SoftwareInstallerID *uint `db:"software_installer_id"`
InHouseAppID *uint `db:"in_house_app_id"`
AdamID *string `db:"adam_id"`
VPPAppTeamID *uint `db:"vpp_app_team_id"`
VPPIconUrl *string `db:"vpp_icon_url"`
+57 -2
View File
@@ -141,7 +141,7 @@ func TestUploadSoftwareTitleIcon(t *testing.T) {
testFunc func(*testing.T)
}{
{
name: "upload icon title with no software installer or vpp app",
name: "upload icon title with no software installer, vpp app, or in-house app",
before: func() {
capturedActivities = make([]fleet.ActivityDetails, 0)
ds.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint, includeUnpublished bool) (*fleet.SoftwareInstaller, error) {
@@ -150,6 +150,10 @@ func TestUploadSoftwareTitleIcon(t *testing.T) {
ds.GetVPPAppMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint) (*fleet.VPPAppStoreApp, error) {
return nil, ctxerr.Wrap(ctx, &common_mysql.NotFoundError{ResourceType: "VPPAppMetadata"}, "get VPP app metadata")
}
ds.GetInHouseAppMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint) (*fleet.SoftwareInstaller, error) {
return nil, ctxerr.Wrap(ctx, &common_mysql.NotFoundError{ResourceType: "InHouseAppMetadata"}, "get in-house app metadata")
}
file, err := os.Open("testdata/icons/valid-icon.png")
require.NoError(t, err)
defer file.Close()
@@ -165,7 +169,7 @@ func TestUploadSoftwareTitleIcon(t *testing.T) {
}
_, err := svc.UploadSoftwareTitleIcon(ctx, payload)
require.Error(t, err)
require.Contains(t, err.Error(), "Software title has no software installer or VPP app")
require.Contains(t, err.Error(), "Software title has no software installer, VPP app, or in-house app")
},
},
{
@@ -271,6 +275,57 @@ func TestUploadSoftwareTitleIcon(t *testing.T) {
require.Len(t, capturedActivities, 1)
},
},
{
name: "upload icon for in-house app",
before: func() {
capturedActivities = make([]fleet.ActivityDetails, 0)
ds.GetInHouseAppMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint) (*fleet.SoftwareInstaller, error) {
return &fleet.SoftwareInstaller{
TitleID: ptr.Uint(1),
TeamID: ptr.Uint(1),
}, nil
}
ds.GetSoftwareTitleIconFunc = func(ctx context.Context, teamID, titleID uint) (*fleet.SoftwareTitleIcon, error) {
return nil, ctxerr.Wrap(ctx, &common_mysql.NotFoundError{ResourceType: "SoftwareTitleIcon"}, "get software title icon")
}
ds.GetTeamIdsForIconStorageIdFunc = func(ctx context.Context, storageID string) ([]uint, error) {
return []uint{1}, nil
}
ds.CreateOrUpdateSoftwareTitleIconFunc = func(ctx context.Context, payload *fleet.UploadSoftwareTitleIconPayload) (*fleet.SoftwareTitleIcon, error) {
sha, err := file.SHA256FromTempFileReader(iconFile)
require.NoError(t, err)
return &fleet.SoftwareTitleIcon{
TeamID: 1,
SoftwareTitleID: 1,
StorageID: sha,
Filename: "icon.png",
}, nil
}
ds.ActivityDetailsForSoftwareTitleIconFunc = func(ctx context.Context, teamID uint, titleID uint) (fleet.DetailsForSoftwareIconActivity, error) {
return fleet.DetailsForSoftwareIconActivity{
InHouseAppID: ptr.Uint(1),
SoftwareTitle: "foo",
Filename: ptr.String("icon.png"),
TeamName: ptr.String("team1"),
TeamID: 1,
SoftwareTitleID: 1,
}, nil
}
},
testFunc: func(t *testing.T) {
payload := &fleet.UploadSoftwareTitleIconPayload{
TitleID: 1,
TeamID: 1,
IconFile: iconFile,
Filename: "icon.png",
}
_, err := svc.UploadSoftwareTitleIcon(ctx, payload)
require.NoError(t, err)
require.Len(t, capturedActivities, 1)
},
},
}
for _, tc := range testCases {