software display names: DB changes (#35066)

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

# Checklist for submitter

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

- [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] 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.
- [x] Ensured the correct collation is explicitly set for character
columns (`COLLATE utf8mb4_unicode_ci`).
This commit is contained in:
Jahziel Villasana-Espinoza 2025-11-04 10:04:42 -05:00 committed by GitHub
parent 475614f19d
commit 1cf16f0539
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 646 additions and 28 deletions

View file

@ -797,6 +797,7 @@ spec:
software_package: null
source: chrome_extensions
extension_for: chrome
display_name: ""
browser: chrome
versions:
- id: 0
@ -821,6 +822,7 @@ spec:
software_package: null
source: deb_packages
extension_for: ""
display_name: ""
browser: ""
versions:
- id: 0
@ -839,6 +841,7 @@ spec:
"name": "foo",
"source": "chrome_extensions",
"extension_for": "chrome",
"display_name": "",
"browser": "chrome",
"hosts_count": 2,
"icon_url": null,
@ -874,6 +877,7 @@ spec:
"id": 0,
"name": "bar",
"source": "deb_packages",
"display_name": "",
"extension_for": "",
"browser": "",
"hosts_count": 0,
@ -950,6 +954,7 @@ spec:
source: chrome_extensions
browser: chrome
extension_for: chrome
display_name: ""
version: 0.0.1
vulnerabilities:
- cve: cve-321-432-543
@ -966,6 +971,7 @@ spec:
extension_id: xyz
browser: edge
extension_for: edge
display_name: ""
vulnerabilities: null
- generated_cpe: someothercpewithoutvulns
id: 0
@ -973,6 +979,7 @@ spec:
source: chrome_extensions
browser: chrome
extension_for: chrome
display_name: ""
version: 0.0.3
vulnerabilities: null
- bundle_identifier: bundle
@ -982,6 +989,7 @@ spec:
source: deb_packages
browser: ""
extension_for: ""
display_name: ""
version: 0.0.3
vulnerabilities: null
`
@ -998,6 +1006,7 @@ spec:
"source": "chrome_extensions",
"browser": "chrome",
"extension_for": "chrome",
"display_name": "",
"generated_cpe": "somecpe",
"vulnerabilities": [
{
@ -1020,6 +1029,7 @@ spec:
"extension_id": "xyz",
"browser": "edge",
"extension_for": "edge",
"display_name": "",
"generated_cpe": "",
"vulnerabilities": null
},
@ -1030,6 +1040,7 @@ spec:
"source": "chrome_extensions",
"browser": "chrome",
"extension_for": "chrome",
"display_name": "",
"generated_cpe": "someothercpewithoutvulns",
"vulnerabilities": null
},
@ -1039,8 +1050,9 @@ spec:
"version": "0.0.3",
"bundle_identifier": "bundle",
"source": "deb_packages",
"display_name": "",
"browser": "",
"extension_for": "",
"extension_for": "",
"generated_cpe": "",
"vulnerabilities": null
}

View file

@ -0,0 +1,34 @@
package tables
import (
"database/sql"
"github.com/pkg/errors"
)
func init() {
MigrationClient.AddMigration(Up_20251103160848, Down_20251103160848)
}
func Up_20251103160848(tx *sql.Tx) error {
_, err := tx.Exec(`
CREATE TABLE IF NOT EXISTS software_title_display_names (
id INT AUTO_INCREMENT PRIMARY KEY,
team_id INT UNSIGNED NOT NULL,
software_title_id INT UNSIGNED NOT NULL,
display_name varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
created_at timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
UNIQUE KEY idx_unique_team_id_title_id (team_id, software_title_id),
FOREIGN KEY (software_title_id) REFERENCES software_titles(id) ON DELETE CASCADE ON UPDATE CASCADE
)
`)
if err != nil {
return errors.Wrapf(err, "create software_title_display_names table")
}
return nil
}
func Down_20251103160848(tx *sql.Tx) error {
return nil
}

View file

@ -0,0 +1,11 @@
package tables
import "testing"
func TestUp_20251103160848(t *testing.T) {
db := applyUpToPrev(t)
// Apply current migration.
applyNext(t, db)
}

File diff suppressed because one or more lines are too long

View file

@ -1275,6 +1275,7 @@ func listSoftwareDB(
}
softwares[idx].Vulnerabilities = append(softwares[idx].Vulnerabilities, cve)
}
}
return softwares, nil
@ -1328,6 +1329,7 @@ func selectSoftwareSQL(opts fleet.SoftwareListOptions) (string, []interface{}, e
"s.vendor",
"s.arch",
"s.application_id",
"s.title_id",
goqu.I("scp.cpe").As("generated_cpe"),
).
// Include this in the sub-query in case we want to sort by 'generated_cpe'
@ -1517,6 +1519,7 @@ func selectSoftwareSQL(opts fleet.SoftwareListOptions) (string, []interface{}, e
"s.vendor",
"s.arch",
"s.application_id",
"s.title_id",
goqu.COALESCE(goqu.I("s.generated_cpe"), "").As("generated_cpe"),
"scv.cve",
"scv.created_at",
@ -1785,6 +1788,30 @@ func (ds *Datastore) ListSoftware(ctx context.Context, opt fleet.SoftwareListOpt
return nil, nil, err
}
var titleIDs []uint
for _, s := range software {
if s.TitleID != nil {
titleIDs = append(titleIDs, *s.TitleID)
}
}
var tmID uint
if opt.TeamID != nil {
tmID = *opt.TeamID
}
displayNames, err := ds.getDisplayNamesByTeamAndTitleIds(ctx, tmID, titleIDs)
if err != nil && !fleet.IsNotFound(err) {
return nil, nil, ctxerr.Wrap(ctx, err, "get software display names by team and title IDs")
}
for i, s := range software {
if s.TitleID != nil {
if displayName, ok := displayNames[*s.TitleID]; ok {
software[i].DisplayName = displayName
}
}
}
perPage := opt.ListOptions.PerPage
var metaData *fleet.PaginationMetadata
if opt.ListOptions.IncludeMetadata {
@ -1849,6 +1876,7 @@ func (ds *Datastore) SoftwareByID(ctx context.Context, id uint, teamID *uint, in
"s.vendor",
"s.arch",
"s.extension_id",
"s.title_id",
"scv.cve",
"scv.created_at",
goqu.COALESCE(goqu.I("scp.cpe"), "").As("generated_cpe"),
@ -1931,6 +1959,19 @@ func (ds *Datastore) SoftwareByID(ctx context.Context, id uint, teamID *uint, in
software = result.Software
}
var tmID uint
if teamID != nil {
tmID = *teamID
}
if software.TitleID != nil {
displayName, err := ds.getSoftwareTitleDisplayName(ctx, tmID, *software.TitleID)
if err != nil && !fleet.IsNotFound(err) {
return nil, ctxerr.Wrap(ctx, err, "getting display name for software")
}
software.DisplayName = displayName
}
if result.CVE != nil {
cveID := *result.CVE
cve := fleet.CVE{

View file

@ -669,6 +669,10 @@ func (ds *Datastore) SaveInstallerUpdates(ctx context.Context, payload *fleet.Up
}
}
if err := updateSoftwareTitleDisplayName(ctx, tx, payload.TeamID, payload.TitleID, payload.DisplayName); err != nil {
return ctxerr.Wrap(ctx, err, "update software title display name")
}
return nil
})
if err != nil {

View file

@ -52,6 +52,7 @@ func TestSoftwareInstallers(t *testing.T) {
{"BatchSetSoftwareInstallersActivateNextActivity", testBatchSetSoftwareInstallersActivateNextActivity},
{"SaveInstallerUpdatesClearsFleetMaintainedAppID", testSaveInstallerUpdatesClearsFleetMaintainedAppID},
{"SoftwareInstallerReplicaLag", testSoftwareInstallerReplicaLag},
{"SoftwareTitleDisplayName", testSoftwareTitleDisplayName},
}
for _, c := range cases {
@ -3343,7 +3344,7 @@ func testSaveInstallerUpdatesClearsFleetMaintainedAppID(t *testing.T, ds *Datast
require.NoError(t, err)
// Create an installer with a non-NULL fleet_maintained_app_id
installerID, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
installerID, titleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "testpkg",
Source: "apps",
InstallScript: "echo install",
@ -3365,6 +3366,7 @@ func testSaveInstallerUpdatesClearsFleetMaintainedAppID(t *testing.T, ds *Datast
preInstallQuery := "SELECT 2"
selfService := true
payload := &fleet.UpdateSoftwareInstallerPayload{
TitleID: titleID,
InstallerID: installerID,
StorageID: "storageid2", // different storage id
Filename: "test2.pkg",
@ -3423,3 +3425,197 @@ func testSoftwareInstallerReplicaLag(t *testing.T, _ *Datastore) {
require.NoError(t, err)
require.NotNil(t, gotInstaller)
}
func testSoftwareTitleDisplayName(t *testing.T, ds *Datastore) {
ctx := context.Background()
tfr1, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
require.NoError(t, err)
user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
host0 := test.NewHost(t, ds, "host0", "", "host0key", "host0uuid", time.Now())
_, titleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallerFile: tfr1,
Extension: "msi",
StorageID: "storageid",
Filename: "originalname.msi",
Title: "OriginalName1",
PackageIDs: []string{"id2"},
Version: "2.0",
Source: "programs",
AutomaticInstall: true,
UserID: user1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
// Display name is empty by default
titles, _, _, err := ds.ListSoftwareTitles(
ctx,
fleet.SoftwareTitleListOptions{TeamID: ptr.Uint(0)},
fleet.TeamFilter{User: &fleet.User{
GlobalRole: ptr.String(fleet.RoleAdmin),
}},
)
require.NoError(t, err)
assert.Len(t, titles, 1)
assert.Empty(t, titles[0].DisplayName)
title, err := ds.SoftwareTitleByID(ctx, titleID, ptr.Uint(0), fleet.TeamFilter{})
require.NoError(t, err)
assert.Empty(t, title.DisplayName)
err = ds.SaveInstallerUpdates(ctx, &fleet.UpdateSoftwareInstallerPayload{
DisplayName: "update1",
TitleID: titleID,
InstallerFile: &fleet.TempFileReader{},
InstallScript: new(string),
PreInstallQuery: new(string),
PostInstallScript: new(string),
SelfService: ptr.Bool(false),
UninstallScript: new(string),
})
require.NoError(t, err)
// Display name entry should be in join table
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
type result struct {
DisplayName string `db:"display_name"`
SoftwareTitleID uint `db:"software_title_id"`
TeamID uint `db:"team_id"`
}
var r []result
err := sqlx.SelectContext(ctx, q, &r, "SELECT display_name, software_title_id, team_id FROM software_title_display_names")
require.NoError(t, err)
assert.Len(t, r, 1)
assert.Equal(t, r[0], result{"update1", titleID, 0})
return nil
})
// List contains display name
titles, _, _, err = ds.ListSoftwareTitles(
ctx,
fleet.SoftwareTitleListOptions{TeamID: ptr.Uint(0)},
fleet.TeamFilter{User: &fleet.User{
GlobalRole: ptr.String(fleet.RoleAdmin),
}},
)
require.NoError(t, err)
assert.Len(t, titles, 1)
assert.Equal(t, "update1", titles[0].DisplayName)
// Entity contains display name
title, err = ds.SoftwareTitleByID(ctx, titleID, ptr.Uint(0), fleet.TeamFilter{})
require.NoError(t, err)
assert.Equal(t, "update1", title.DisplayName)
// Update host's software so we get a software version
software0 := []fleet.Software{
{Name: "OriginalName1", Version: "0.0.1", Source: "programs", TitleID: ptr.Uint(titleID)},
}
_, err = ds.UpdateHostSoftware(ctx, host0.ID, software0)
require.NoError(t, err)
require.NoError(t, ds.SyncHostsSoftware(ctx, time.Now()))
softwareList, _, err := ds.ListSoftware(ctx, fleet.SoftwareListOptions{})
require.NoError(t, err)
assert.Len(t, softwareList, 1)
assert.Equal(t, titleID, *softwareList[0].TitleID)
assert.Equal(t, "update1", softwareList[0].DisplayName)
software, err := ds.SoftwareByID(ctx, softwareList[0].ID, ptr.Uint(0), false, &fleet.TeamFilter{User: &fleet.User{
GlobalRole: ptr.String(fleet.RoleAdmin),
}})
require.NoError(t, err)
assert.Equal(t, titleID, *software.TitleID)
assert.Equal(t, "update1", software.DisplayName)
// Update the display name again, should see the change
err = ds.SaveInstallerUpdates(ctx, &fleet.UpdateSoftwareInstallerPayload{
DisplayName: "update2",
TitleID: titleID,
InstallerFile: &fleet.TempFileReader{},
InstallScript: new(string),
PreInstallQuery: new(string),
PostInstallScript: new(string),
SelfService: ptr.Bool(false),
UninstallScript: new(string),
})
require.NoError(t, err)
// List contains display name
titles, _, _, err = ds.ListSoftwareTitles(
ctx,
fleet.SoftwareTitleListOptions{TeamID: ptr.Uint(0)},
fleet.TeamFilter{User: &fleet.User{
GlobalRole: ptr.String(fleet.RoleAdmin),
}},
)
require.NoError(t, err)
assert.Len(t, titles, 1)
assert.Equal(t, "update2", titles[0].DisplayName)
// Entity contains display name
title, err = ds.SoftwareTitleByID(ctx, titleID, ptr.Uint(0), fleet.TeamFilter{})
require.NoError(t, err)
assert.Equal(t, "update2", title.DisplayName)
softwareList, _, err = ds.ListSoftware(ctx, fleet.SoftwareListOptions{})
require.NoError(t, err)
assert.Len(t, softwareList, 1)
assert.Equal(t, titleID, *softwareList[0].TitleID)
assert.Equal(t, "update2", softwareList[0].DisplayName)
software, err = ds.SoftwareByID(ctx, softwareList[0].ID, ptr.Uint(0), false, &fleet.TeamFilter{User: &fleet.User{
GlobalRole: ptr.String(fleet.RoleAdmin),
}})
require.NoError(t, err)
assert.Equal(t, titleID, *software.TitleID)
assert.Equal(t, "update2", software.DisplayName)
// Update display name to be empty
err = ds.SaveInstallerUpdates(ctx, &fleet.UpdateSoftwareInstallerPayload{
TitleID: titleID,
InstallerFile: &fleet.TempFileReader{},
InstallScript: new(string),
PreInstallQuery: new(string),
PostInstallScript: new(string),
SelfService: ptr.Bool(false),
UninstallScript: new(string),
})
require.NoError(t, err)
// List contains display name
titles, _, _, err = ds.ListSoftwareTitles(
ctx,
fleet.SoftwareTitleListOptions{TeamID: ptr.Uint(0)},
fleet.TeamFilter{User: &fleet.User{
GlobalRole: ptr.String(fleet.RoleAdmin),
}},
)
require.NoError(t, err)
assert.Len(t, titles, 1)
assert.Empty(t, titles[0].DisplayName)
// Entity contains display name
title, err = ds.SoftwareTitleByID(ctx, titleID, ptr.Uint(0), fleet.TeamFilter{})
require.NoError(t, err)
assert.Empty(t, title.DisplayName)
softwareList, _, err = ds.ListSoftware(ctx, fleet.SoftwareListOptions{})
require.NoError(t, err)
assert.Len(t, softwareList, 1)
assert.Equal(t, titleID, *softwareList[0].TitleID)
assert.Empty(t, softwareList[0].DisplayName)
software, err = ds.SoftwareByID(ctx, softwareList[0].ID, ptr.Uint(0), false, &fleet.TeamFilter{User: &fleet.User{
GlobalRole: ptr.String(fleet.RoleAdmin),
}})
require.NoError(t, err)
assert.Equal(t, titleID, *software.TitleID)
assert.Empty(t, software.DisplayName)
}

View file

@ -0,0 +1,78 @@
package mysql
import (
"context"
"database/sql"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/jmoiron/sqlx"
)
func updateSoftwareTitleDisplayName(ctx context.Context, tx sqlx.ExtContext, teamID *uint, titleID uint, displayName string) error {
var tmID uint
if teamID != nil {
tmID = *teamID
}
_, err := tx.ExecContext(ctx, `
INSERT INTO software_title_display_names
(team_id, software_title_id, display_name)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE
display_name = VALUES(display_name)`, tmID, titleID, displayName)
if err != nil {
return err
}
return nil
}
func (ds *Datastore) getDisplayNamesByTeamAndTitleIds(ctx context.Context, teamID uint, titleIDs []uint) (map[uint]string, error) {
if len(titleIDs) == 0 {
return map[uint]string{}, nil
}
var args []any
query := `
SELECT software_title_id, display_name
FROM software_title_display_names
WHERE software_title_id IN (?) AND team_id = ?
`
query, args, err := sqlx.In(query, titleIDs, teamID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "building query for get software title display names")
}
var results []struct {
SoftwareTitleID uint `db:"software_title_id"`
DisplayName string `db:"display_name"`
}
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &results, query, args...); err != nil {
return nil, ctxerr.Wrap(ctx, err, "get software title display names")
}
iconsBySoftwareTitleID := make(map[uint]string, len(results))
for _, r := range results {
iconsBySoftwareTitleID[r.SoftwareTitleID] = r.DisplayName
}
return iconsBySoftwareTitleID, nil
}
func (ds *Datastore) getSoftwareTitleDisplayName(ctx context.Context, teamID uint, titleID uint) (string, error) {
args := []any{teamID, titleID}
query := `
SELECT display_name
FROM software_title_display_names
WHERE team_id = ? AND software_title_id = ?
`
var displayName string
err := sqlx.GetContext(ctx, ds.reader(ctx), &displayName, query, args...)
if err != nil {
if err == sql.ErrNoRows {
return "", ctxerr.Wrap(ctx, notFound("SoftwareTitleDisplayName"), "get software title display name")
}
return "", ctxerr.Wrap(ctx, err, "get software title display name")
}
return displayName, nil
}

View file

@ -12,13 +12,13 @@ import (
func (ds *Datastore) CreateOrUpdateSoftwareTitleIcon(ctx context.Context, payload *fleet.UploadSoftwareTitleIconPayload) (*fleet.SoftwareTitleIcon, error) {
var query string
var args []interface{}
var args []any
query = `
INSERT INTO software_title_icons (team_id, software_title_id, storage_id, filename)
VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE
storage_id = VALUES(storage_id), filename = VALUES(filename)
`
args = []interface{}{payload.TeamID, payload.TitleID, payload.StorageID, payload.Filename}
args = []any{payload.TeamID, payload.TitleID, payload.StorageID, payload.Filename}
_, err := ds.writer(ctx).ExecContext(ctx, query, args...)
if err != nil {
@ -34,7 +34,7 @@ func (ds *Datastore) CreateOrUpdateSoftwareTitleIcon(ctx context.Context, payloa
}
func (ds *Datastore) GetSoftwareTitleIcon(ctx context.Context, teamID uint, titleID uint) (*fleet.SoftwareTitleIcon, error) {
args := []interface{}{teamID, titleID}
args := []any{teamID, titleID}
query := `
SELECT team_id, software_title_id, storage_id, filename
FROM software_title_icons
@ -67,7 +67,7 @@ func (ds *Datastore) GetSoftwareIconsByTeamAndTitleIds(ctx context.Context, team
return map[uint]fleet.SoftwareTitleIcon{}, nil
}
var args []interface{}
var args []any
query := `
SELECT team_id, software_title_id, storage_id, filename
FROM software_title_icons

View file

@ -97,6 +97,13 @@ GROUP BY
if icon != nil {
title.IconUrl = ptr.String(icon.IconUrl())
}
displayName, err := ds.getSoftwareTitleDisplayName(ctx, *teamID, id)
if err != nil && !fleet.IsNotFound(err) {
return nil, ctxerr.Wrap(ctx, err, "get software title display name")
}
title.DisplayName = displayName
}
title.VersionsCount = uint(len(title.Versions))
@ -299,11 +306,22 @@ func (ds *Datastore) ListSoftwareTitles(
return nil, 0, nil, ctxerr.Wrap(ctx, err, "get software icons by team and title IDs")
}
displayNames, err := ds.getDisplayNamesByTeamAndTitleIds(ctx, *opt.TeamID, titleIDs)
if err != nil {
return nil, 0, nil, ctxerr.Wrap(ctx, err, "get software display names by team and title IDs")
}
for _, icon := range icons {
if i, ok := titleIndex[icon.SoftwareTitleID]; ok {
softwareList[i].IconUrl = ptr.String(icon.IconUrl())
}
}
for titleID, i := range titleIndex {
if displayName, ok := displayNames[titleID]; ok {
softwareList[i].DisplayName = displayName
}
}
}
// we grab matching versions separately and build the desired object in

View file

@ -119,6 +119,18 @@ func saveTeamSecretsDB(ctx context.Context, q sqlx.ExtContext, team *fleet.Team)
return applyEnrollSecretsDB(ctx, q, &team.ID, team.Secrets)
}
// teamRefs are the tables referenced by teams.
// These tables are cleared when the team is deleted.
// Analogous to hostRefs.
var teamRefs = []string{
"mdm_apple_configuration_profiles",
"mdm_windows_configuration_profiles",
"mdm_apple_declarations",
"mdm_android_configuration_profiles",
"software_title_icons",
"software_title_display_names",
}
func (ds *Datastore) DeleteTeam(ctx context.Context, tid uint) error {
return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
// Delete team policies first, because policies can have associated installers and scripts
@ -138,24 +150,11 @@ func (ds *Datastore) DeleteTeam(ctx context.Context, tid uint) error {
return ctxerr.Wrapf(ctx, err, "deleting pack_targets for team %d", tid)
}
_, err = tx.ExecContext(ctx, `DELETE FROM mdm_apple_configuration_profiles WHERE team_id=?`, tid)
if err != nil {
return ctxerr.Wrapf(ctx, err, "deleting mdm_apple_configuration_profiles for team %d", tid)
}
_, err = tx.ExecContext(ctx, `DELETE FROM mdm_windows_configuration_profiles WHERE team_id=?`, tid)
if err != nil {
return ctxerr.Wrapf(ctx, err, "deleting mdm_windows_configuration_profiles for team %d", tid)
}
_, err = tx.ExecContext(ctx, `DELETE FROM mdm_apple_declarations WHERE team_id=?`, tid)
if err != nil {
return ctxerr.Wrapf(ctx, err, "deleting mdm_apple_declarations for team %d", tid)
}
_, err = tx.ExecContext(ctx, `DELETE FROM mdm_android_configuration_profiles WHERE team_id=?`, tid)
if err != nil {
return ctxerr.Wrapf(ctx, err, "deleting mdm_android_configuration_profiles for team %d", tid)
for _, table := range teamRefs {
_, err = tx.ExecContext(ctx, fmt.Sprintf(`DELETE FROM %s WHERE team_id=?`, table), tid)
if err != nil {
return ctxerr.Wrapf(ctx, err, "deleting %s for team %d", table, tid)
}
}
return nil
@ -571,7 +570,7 @@ func (ds *Datastore) SaveDefaultTeamConfig(ctx context.Context, config *fleet.Te
}
_, err = ds.writer(ctx).ExecContext(ctx,
`INSERT INTO default_team_config_json(id, json_value) VALUES(1, ?)
`INSERT INTO default_team_config_json(id, json_value) VALUES(1, ?)
ON DUPLICATE KEY UPDATE json_value = VALUES(json_value)`,
configBytes,
)

View file

@ -3,6 +3,7 @@ package mysql
import (
"context"
"encoding/json"
"fmt"
"sort"
"strconv"
"testing"
@ -107,9 +108,80 @@ func testTeamsGetSetDelete(t *testing.T, ds *Datastore) {
})
require.NoError(t, err)
// Add a bunch of data into tables with team IDs that should be cleared when team is deleted
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
res, err := q.ExecContext(context.Background(), fmt.Sprintf(`INSERT INTO software_titles (name, source) VALUES ('MyCoolApp_%s', 'apps')`, tt.name))
if err != nil {
return err
}
titleID, _ := res.LastInsertId()
_, err = q.ExecContext(
context.Background(),
`INSERT INTO
mdm_apple_configuration_profiles (profile_uuid, team_id, identifier, name, mobileconfig, checksum)
VALUES (?, ?, ?, ?, ?, ?)`,
fmt.Sprintf("uuid_%s", tt.name),
0,
fmt.Sprintf("TestPayloadIdentifier_%s", tt.name),
fmt.Sprintf("TestPayloadName_%s", tt.name),
`<?xml version="1.0"`,
[]byte("test"),
)
if err != nil {
return err
}
_, err = q.ExecContext(context.Background(), `
INSERT INTO
mdm_windows_configuration_profiles (team_id, name, syncml, profile_uuid)
VALUES (?, ?, ?, ?)`, 0, fmt.Sprintf("TestPayloadName_%s", tt.name), `<?xml version="1.0"`, fmt.Sprintf("uuid_%s", tt.name))
if err != nil {
return err
}
_, err = q.ExecContext(context.Background(), `
INSERT INTO
mdm_android_configuration_profiles (profile_uuid, team_id, name, raw_json)
VALUES (?, ?, ?, ?)`, fmt.Sprintf("uuid_%s", tt.name), 0, fmt.Sprintf("TestPayloadName_%s", tt.name), `{"foo": "bar"}`)
if err != nil {
return err
}
_, err = q.ExecContext(
context.Background(),
"INSERT INTO software_title_display_names (team_id, software_title_id, display_name) VALUES (?, ?, ?)",
team.ID,
titleID,
"delete_test",
)
if err != nil {
return err
}
_, err = q.ExecContext(
context.Background(),
"INSERT INTO software_title_icons (team_id, software_title_id, storage_id, filename) VALUES (?, ?, ?, ?)",
team.ID,
titleID,
"delete_test",
"delete_test.png",
)
return err
})
err = ds.DeleteTeam(context.Background(), team.ID)
require.NoError(t, err)
// Check that related tables were cleared
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
for _, table := range teamRefs {
var count int
err := sqlx.GetContext(context.Background(), q, &count, fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE team_id = ?`, table), team.ID)
if err != nil {
return err
}
assert.Zero(t, count)
}
return nil
})
newP, err := ds.Pack(context.Background(), p.ID)
require.NoError(t, err)
require.Empty(t, newP.Teams)

View file

@ -616,6 +616,10 @@ func (ds *Datastore) InsertVPPAppWithTeam(ctx context.Context, app *fleet.VPPApp
app.VPPAppTeam.AddedAutomaticInstallPolicy = policy
}
if err := updateSoftwareTitleDisplayName(ctx, tx, teamID, titleID, app.DisplayName); err != nil {
return ctxerr.Wrap(ctx, err, "setting software title display name for vpp app")
}
return nil
})
if err != nil {

View file

@ -39,6 +39,7 @@ func TestVPP(t *testing.T) {
{"TestVPPTokenTeamAssignment", testVPPTokenTeamAssignment},
{"TestGetAllVPPApps", testGetAllVPPApps},
{"TestGetUnverifiedVPPInstallsForHost", testGetUnverifiedVPPInstallsForHost},
{"SoftwareTitleDisplayName", testSoftwareTitleDisplayNameVPP},
}
for _, c := range cases {
@ -2089,3 +2090,127 @@ func testGetUnverifiedVPPInstallsForHost(t *testing.T, ds *Datastore) {
assert.Len(t, x, step.after)
}
}
func testSoftwareTitleDisplayNameVPP(t *testing.T, ds *Datastore) {
ctx := context.Background()
test.CreateInsertGlobalVPPToken(t, ds)
va1, err := ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{
Name: "vpp1", BundleIdentifier: "com.app.vpp1",
VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_1", Platform: fleet.MacOSPlatform}},
}, nil)
require.NoError(t, err)
titleID := va1.TitleID
// Display name is empty by default
titles, _, _, err := ds.ListSoftwareTitles(
ctx,
fleet.SoftwareTitleListOptions{TeamID: ptr.Uint(0)},
fleet.TeamFilter{User: &fleet.User{
GlobalRole: ptr.String(fleet.RoleAdmin),
}},
)
require.NoError(t, err)
assert.Len(t, titles, 1)
assert.Empty(t, titles[0].DisplayName)
title, err := ds.SoftwareTitleByID(ctx, titleID, ptr.Uint(0), fleet.TeamFilter{})
require.NoError(t, err)
assert.Empty(t, title.DisplayName)
_, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{
Name: "vpp1", BundleIdentifier: "com.app.vpp1",
VPPAppTeam: fleet.VPPAppTeam{
VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_1", Platform: fleet.MacOSPlatform},
DisplayName: "vpp_update1",
},
}, nil)
require.NoError(t, err)
// Display name entry should be in join table
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
type result struct {
DisplayName string `db:"display_name"`
SoftwareTitleID uint `db:"software_title_id"`
TeamID uint `db:"team_id"`
}
var r []result
err := sqlx.SelectContext(ctx, q, &r, "SELECT display_name, software_title_id, team_id FROM software_title_display_names")
require.NoError(t, err)
assert.Len(t, r, 1)
assert.Equal(t, r[0], result{"vpp_update1", titleID, 0})
return nil
})
// List contains display name
titles, _, _, err = ds.ListSoftwareTitles(
ctx,
fleet.SoftwareTitleListOptions{TeamID: ptr.Uint(0)},
fleet.TeamFilter{User: &fleet.User{
GlobalRole: ptr.String(fleet.RoleAdmin),
}},
)
require.NoError(t, err)
assert.Len(t, titles, 1)
assert.Equal(t, "vpp_update1", titles[0].DisplayName)
// Entity contains display name
title, err = ds.SoftwareTitleByID(ctx, titleID, ptr.Uint(0), fleet.TeamFilter{})
require.NoError(t, err)
assert.Equal(t, "vpp_update1", title.DisplayName)
// Update the display name again, should see the change
_, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{
Name: "vpp1", BundleIdentifier: "com.app.vpp1",
VPPAppTeam: fleet.VPPAppTeam{
VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_1", Platform: fleet.MacOSPlatform},
DisplayName: "vpp_update2",
},
}, nil)
require.NoError(t, err)
// List contains display name
titles, _, _, err = ds.ListSoftwareTitles(
ctx,
fleet.SoftwareTitleListOptions{TeamID: ptr.Uint(0)},
fleet.TeamFilter{User: &fleet.User{
GlobalRole: ptr.String(fleet.RoleAdmin),
}},
)
require.NoError(t, err)
assert.Len(t, titles, 1)
assert.Equal(t, "vpp_update2", titles[0].DisplayName)
// Entity contains display name
title, err = ds.SoftwareTitleByID(ctx, titleID, ptr.Uint(0), fleet.TeamFilter{})
require.NoError(t, err)
assert.Equal(t, "vpp_update2", title.DisplayName)
// Update display name to be empty
_, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{
Name: "vpp1", BundleIdentifier: "com.app.vpp1",
VPPAppTeam: fleet.VPPAppTeam{
VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_1", Platform: fleet.MacOSPlatform},
},
}, nil)
require.NoError(t, err)
// List contains display name
titles, _, _, err = ds.ListSoftwareTitles(
ctx,
fleet.SoftwareTitleListOptions{TeamID: ptr.Uint(0)},
fleet.TeamFilter{User: &fleet.User{
GlobalRole: ptr.String(fleet.RoleAdmin),
}},
)
require.NoError(t, err)
assert.Len(t, titles, 1)
assert.Empty(t, titles[0].DisplayName)
// Entity contains display name
title, err = ds.SoftwareTitleByID(ctx, titleID, ptr.Uint(0), fleet.TeamFilter{})
require.NoError(t, err)
assert.Empty(t, title.DisplayName)
}

View file

@ -100,6 +100,8 @@ type Software struct {
IsKernel bool `json:"-"`
// ApplicationID is the unique identifier for Android software. Equivalent to the BundleIdentifier on Apple software.
ApplicationID *string `json:"application_id,omitempty" db:"application_id"`
DisplayName string `json:"display_name"`
}
func (Software) AuthzType() string {
@ -249,6 +251,8 @@ type SoftwareTitle struct {
IsKernel bool `json:"-" db:"is_kernel"`
// ApplicationID is the unique identifier for Android software. Equivalent to the BundleIdentifier on Apple software.
ApplicationID *string `json:"application_id,omitempty" db:"application_id"`
// DisplayName is an end-user friendly name.
DisplayName string `json:"display_name" db:"display_name"`
}
// populateBrowserField populates the browser field for backwards compatibility
@ -330,6 +334,7 @@ type SoftwareTitleListResult struct {
HashSHA256 *string `json:"hash_sha256,omitempty" db:"package_storage_id"`
// ApplicationID is the unique identifier for Android software. Equivalent to the BundleIdentifier on Apple software.
ApplicationID *string `json:"application_id,omitempty" db:"application_id"`
DisplayName string `json:"display_name" db:"display_name"`
}
type SoftwareTitleListOptions struct {

View file

@ -563,6 +563,8 @@ type UpdateSoftwareInstallerPayload struct {
ValidatedLabels *LabelIdentsWithScope
Categories []string
CategoryIDs []uint
// DisplayName is an end-user friendly name.
DisplayName string
}
// DownloadSoftwareInstallerPayload is the payload for downloading a software installer.

View file

@ -241,6 +241,7 @@ func TestHostSoftwareEntryMarshalJSON(t *testing.T) {
"source": "chrome_extensions",
"extension_id": "test-extension-id",
"extension_for": "chrome",
"display_name": "",
"browser": "chrome",
"release": "1",
"vendor": "Test Vendor",

View file

@ -46,6 +46,7 @@ type VPPAppTeam struct {
// automatically created when a VPP app is added to Fleet. This field should be set after VPP
// app creation if AddAutoInstallPolicy is true.
AddedAutomaticInstallPolicy *Policy `json:"-"`
DisplayName string `json:"-"`
}
// VPPApp represents a VPP (Volume Purchase Program) application,

View file

@ -39,7 +39,8 @@ func ElementsMatchSkipIDAndHostCount(t TestingT, listA, listB interface{}, msgAn
opt := cmp.FilterPath(func(p cmp.Path) bool {
for _, ps := range p {
if ps, ok := ps.(cmp.StructField); ok && (ps.Name() == "ID" || ps.Name() == "HostCount") {
// We don't need the TitleID in most cases, only used for software title display names functionality.
if ps, ok := ps.(cmp.StructField); ok && (ps.Name() == "ID" || ps.Name() == "HostCount" || ps.Name() == "TitleID") {
return true
}
}