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:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user