Self-service categories - migration (#46488)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #46390 
Migration to add a non nullable team_id column. I chose this approach so
that server code doesn't have to deal with a potentially null team id
for categories. It first adds team_id=0 to all the existing categories,
then duplicates that for every fleet so that default categories can be
edited and deleted by admins.

It also renames the default categories to include the emojis in their
name, which required updating some test expectations, and also mapping
default names to the new ones for Fleet maintained apps. If we don't do
that, FMA's manifests would have to all be updated right after 4.87
releases and every user would have to migrate immediately. This would
also break existing gitops files if the names aren't mapped. The
alternative would be to keep the names unchanged, and add custom logic
in various places to insert the emojis in the backend and frontend.

# Checklist for submitter

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


## 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

## Database migrations

- [x] Checked schema for all modified table for columns that will
auto-update timestamps during migration.
- [x] Confirmed that updating the timestamps is acceptable, and will not
cause unwanted side effects.
- [x] Ensured the correct collation is explicitly set for character
columns (`COLLATE utf8mb4_unicode_ci`).
This commit is contained in:
Jonathan Katz
2026-05-29 18:38:07 -04:00
committed by GitHub
parent 9e10c3cc22
commit 57d78c54d1
10 changed files with 351 additions and 25 deletions
+1 -1
View File
@@ -852,7 +852,7 @@ func testBatchSetInHouseInstallers(t *testing.T, ds *Datastore) {
// change ipa2 self-service and add categories
ipa2.SelfService = !ipa2.SelfService
ipa2.Categories = []string{"Communication", "Productivity"}
ipa2.Categories = []string{"👬 Communication", "💻 Productivity"}
catIDs, err := ds.GetSoftwareCategoryIDs(ctx, ipa2.Categories)
require.NoError(t, err)
ipa2.CategoryIDs = catIDs
@@ -0,0 +1,127 @@
package tables
import (
"database/sql"
"github.com/pkg/errors"
)
func init() {
MigrationClient.AddMigration(Up_20260529203429, Down_20260529203429)
}
func Up_20260529203429(tx *sql.Tx) error {
return withSteps([]migrationStep{
// Table update: add team scoping columns, swap unique index, rename defaults.
func(tx *sql.Tx) error {
if _, err := tx.Exec(`
ALTER TABLE software_categories
MODIFY COLUMN name VARCHAR(255) NOT NULL,
ADD COLUMN team_id INT UNSIGNED NOT NULL DEFAULT 0,
ADD COLUMN created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
ADD COLUMN updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)
`); err != nil {
return errors.Wrap(err, "adding team scoping columns to software_categories")
}
// Index changes: drop the old name-only unique index (names will repeat
// across teams) and add the new (team_id, name) unique index.
if _, err := tx.Exec(`
ALTER TABLE software_categories
DROP INDEX idx_software_categories_name,
ADD UNIQUE KEY idx_software_categories_team_id_name (team_id, name)
`); err != nil {
return errors.Wrap(err, "swapping uniqueness scope to (team_id, name)")
}
// Rename the previously seeded defaults to their emoji-prefixed forms.
// IDs are preserved.
if _, err := tx.Exec(`
UPDATE software_categories
SET name = CASE name
WHEN 'Browsers' THEN '🌎 Browsers'
WHEN 'Communication' THEN '👬 Communication'
WHEN 'Developer tools' THEN '🧰 Developer tools'
WHEN 'Productivity' THEN '💻 Productivity'
WHEN 'Security' THEN '🔐 Security'
WHEN 'Utilities' THEN '🛟 Support'
ELSE name
END
WHERE team_id = 0
`); err != nil {
return errors.Wrap(err, "renaming default categories to emoji-prefixed names")
}
// Pin both timestamps to a constant so the generated schema is
// deterministic across repeated runs of make dump-test-schema.
// The previous UPDATE bumped updated_at via ON UPDATE CURRENT_TIMESTAMP
// and the ADD COLUMN filled created_at with the migration run time.
if _, err := tx.Exec(`UPDATE software_categories SET created_at = '2026-05-29 00:00:00', updated_at = '2026-05-29 00:00:00' WHERE team_id = 0`); err != nil {
return errors.Wrap(err, "pinning timestamps for schema dump stability")
}
return nil
},
// Backfill: copy defaults per fleet and re-point existing category links.
func(tx *sql.Tx) error {
// give every existing fleet its own copy of the 6 defaults.
if _, err := tx.Exec(`
INSERT INTO software_categories (name, team_id)
SELECT sc.name, t.id
FROM software_categories sc
CROSS JOIN teams t
WHERE sc.team_id = 0
ORDER BY t.id, FIELD(sc.name,
'🌎 Browsers',
'👬 Communication',
'🧰 Developer tools',
'💻 Productivity',
'🔐 Security',
'🛟 Support')
`); err != nil {
return errors.Wrap(err, "backfilling per-fleet default categories")
}
// Re-point each link from the team_id=0 source row to the team's row
// with the same name (joining old_sc and new_sc on name).
if _, err := tx.Exec(`
UPDATE software_installer_software_categories sisc
JOIN software_installers si ON sisc.software_installer_id = si.id
JOIN software_categories old_sc ON sisc.software_category_id = old_sc.id
JOIN software_categories new_sc ON new_sc.team_id = si.global_or_team_id AND new_sc.name = old_sc.name
SET sisc.software_category_id = new_sc.id
WHERE si.global_or_team_id != 0 AND old_sc.team_id = 0
`); err != nil {
return errors.Wrap(err, "re-pointing software installer category links")
}
// Same for VPP app category links.
if _, err := tx.Exec(`
UPDATE vpp_app_team_software_categories vatsc
JOIN vpp_apps_teams vat ON vatsc.vpp_app_team_id = vat.id
JOIN software_categories old_sc ON vatsc.software_category_id = old_sc.id
JOIN software_categories new_sc ON new_sc.team_id = vat.global_or_team_id AND new_sc.name = old_sc.name
SET vatsc.software_category_id = new_sc.id
WHERE vat.global_or_team_id != 0 AND old_sc.team_id = 0
`); err != nil {
return errors.Wrap(err, "re-pointing VPP app category links")
}
// Same for in-house app category links.
if _, err := tx.Exec(`
UPDATE in_house_app_software_categories ihasc
JOIN in_house_apps iha ON ihasc.in_house_app_id = iha.id
JOIN software_categories old_sc ON ihasc.software_category_id = old_sc.id
JOIN software_categories new_sc ON new_sc.team_id = iha.global_or_team_id AND new_sc.name = old_sc.name
SET ihasc.software_category_id = new_sc.id
WHERE iha.global_or_team_id != 0 AND old_sc.team_id = 0
`); err != nil {
return errors.Wrap(err, "re-pointing in-house app category links")
}
return nil
},
}, tx)
}
func Down_20260529203429(tx *sql.Tx) error {
return nil
}
@@ -0,0 +1,163 @@
package tables
import (
"database/sql"
"testing"
"github.com/stretchr/testify/require"
)
func TestUp_20260529203429(t *testing.T) {
db := applyUpToPrev(t)
type seededCategory struct {
ID uint `db:"id"`
Name string `db:"name"`
}
var seeded []seededCategory
require.NoError(t, db.Select(&seeded, `SELECT id, name FROM software_categories ORDER BY id`))
require.NotEmpty(t, seeded, "earlier migrations should have seeded default categories")
preID := make(map[string]uint, len(seeded))
for _, s := range seeded {
preID[s.Name] = s.ID
}
// Two teams plus apps on each (and one on the Unassigned scope) so the
// migration has to: backfill defaults per team, re-point team-scoped link
// rows in all three linking tables, and leave Unassigned-scope link rows
// untouched.
teamA := uint(execNoErrLastID(t, db, `INSERT INTO teams (name) VALUES (?)`, "team-a")) //nolint:gosec // dismiss G115
teamB := uint(execNoErrLastID(t, db, `INSERT INTO teams (name) VALUES (?)`, "team-b")) //nolint:gosec // dismiss G115
titleID := execNoErrLastID(t, db, `INSERT INTO software_titles (name, source) VALUES (?, ?)`, "demo", "apps")
execNoErr(t, db, `INSERT INTO script_contents (id, md5_checksum, contents) VALUES (1, 'demo-checksum', 'demo')`)
const insertInstaller = `
INSERT INTO software_installers (title_id, global_or_team_id, filename, version, platform, install_script_content_id, uninstall_script_content_id, storage_id, package_ids, patch_query)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
installerA := execNoErrLastID(t, db, insertInstaller, titleID, teamA, "a.pkg", "1.0", "darwin", 1, 1, "storage-a", "", "")
installerUnassigned := execNoErrLastID(t, db, insertInstaller, titleID, 0, "unassigned.pkg", "1.0", "darwin", 1, 1, "storage-u", "", "")
browsersID := preID["Browsers"]
communicationID := preID["Communication"]
execNoErr(t, db, `INSERT INTO software_installer_software_categories (software_installer_id, software_category_id) VALUES (?, ?)`, installerA, browsersID)
execNoErr(t, db, `INSERT INTO software_installer_software_categories (software_installer_id, software_category_id) VALUES (?, ?)`, installerUnassigned, communicationID)
// VPP app on team B linked to "Developer tools".
execNoErr(t, db, `INSERT INTO vpp_apps (adam_id, platform) VALUES (?, ?)`, "1234567890", "darwin")
vppAppTeamB := execNoErrLastID(t, db, `INSERT INTO vpp_apps_teams (adam_id, global_or_team_id, platform) VALUES (?, ?, ?)`, "1234567890", teamB, "darwin")
devToolsID := preID["Developer tools"]
execNoErr(t, db, `INSERT INTO vpp_app_team_software_categories (vpp_app_team_id, software_category_id) VALUES (?, ?)`, vppAppTeamB, devToolsID)
// In-house app on team A linked to "Productivity".
inHouseAppA := execNoErrLastID(t, db, `INSERT INTO in_house_apps (global_or_team_id, storage_id, platform) VALUES (?, ?, ?)`, teamA, "storage-ih", "darwin")
productivityID := preID["Productivity"]
execNoErr(t, db, `INSERT INTO in_house_app_software_categories (in_house_app_id, software_category_id) VALUES (?, ?)`, inHouseAppA, productivityID)
applyNext(t, db)
type categoryRow struct {
ID uint `db:"id"`
Name string `db:"name"`
TeamID uint `db:"team_id"`
}
// Defaults renamed to emoji-prefixed forms; IDs preserved.
expectedRenames := map[string]string{
"Browsers": "🌎 Browsers",
"Communication": "👬 Communication",
"Developer tools": "🧰 Developer tools",
"Productivity": "💻 Productivity",
"Security": "🔐 Security",
"Utilities": "🛟 Support",
}
for oldName, newName := range expectedRenames {
oldID, ok := preID[oldName]
if !ok {
continue
}
var row categoryRow
require.NoError(t, db.Get(&row, `SELECT id, name, team_id FROM software_categories WHERE id = ?`, oldID))
require.Equal(t, newName, row.Name, "row %q should be renamed to %q", oldName, newName)
require.Equal(t, uint(0), row.TeamID, "renamed row should be at team_id=0")
}
canonicalNames := []string{
"🌎 Browsers",
"👬 Communication",
"🧰 Developer tools",
"💻 Productivity",
"🔐 Security",
"🛟 Support",
}
// Both teams have all 6 defaults in canonical order with sequential IDs.
assertTeamCategories := func(teamID uint) []categoryRow {
var rows []categoryRow
require.NoError(t, db.Select(&rows,
`SELECT id, name, team_id FROM software_categories WHERE team_id = ? ORDER BY id`, teamID))
require.Len(t, rows, len(canonicalNames), "team %d should have all 6 default categories", teamID)
for i, r := range rows {
require.Equal(t, canonicalNames[i], r.Name, "team %d row %d should be %q", teamID, i, canonicalNames[i])
if i > 0 {
require.Equal(t, rows[i-1].ID+1, r.ID, "team %d rows should have sequential ids", teamID)
}
}
return rows
}
teamARows := assertTeamCategories(teamA)
teamBRows := assertTeamCategories(teamB)
require.NotEqual(t, teamARows[0].ID, teamBRows[0].ID, "team A and team B should have distinct id blocks")
// Team A's installer link now points at team A's "🌎 Browsers", not the
// renamed team_id=0 row.
var installerALinkedCatID uint
require.NoError(t, db.Get(&installerALinkedCatID,
`SELECT software_category_id FROM software_installer_software_categories WHERE software_installer_id = ?`,
installerA))
require.Equal(t, teamARows[0].ID, installerALinkedCatID, "team A installer should link to team A's 🌎 Browsers")
require.Equal(t, "🌎 Browsers", teamARows[0].Name)
// Unassigned installer link is unchanged — still pointing at the renamed
// team_id=0 Communication row.
var unassignedLinkedCatID uint
require.NoError(t, db.Get(&unassignedLinkedCatID,
`SELECT software_category_id FROM software_installer_software_categories WHERE software_installer_id = ?`,
installerUnassigned))
require.Equal(t, communicationID, unassignedLinkedCatID, "Unassigned installer link should be untouched")
// VPP app on team B was re-pointed.
var vppLinkedCatID uint
require.NoError(t, db.Get(&vppLinkedCatID,
`SELECT software_category_id FROM vpp_app_team_software_categories WHERE vpp_app_team_id = ?`,
vppAppTeamB))
require.Equal(t, teamBRows[2].ID, vppLinkedCatID, "team B VPP app should link to team B's 🧰 Developer tools")
require.Equal(t, "🧰 Developer tools", teamBRows[2].Name)
// In-house app on team A was re-pointed.
var inHouseLinkedCatID uint
require.NoError(t, db.Get(&inHouseLinkedCatID,
`SELECT software_category_id FROM in_house_app_software_categories WHERE in_house_app_id = ?`,
inHouseAppA))
require.Equal(t, teamARows[3].ID, inHouseLinkedCatID, "team A in-house app should link to team A's 💻 Productivity")
require.Equal(t, "💻 Productivity", teamARows[3].Name)
assertLinkGone := func(query string, parentID uint, label string) {
var dummy uint
err := db.Get(&dummy, query, parentID)
require.ErrorIs(t, err, sql.ErrNoRows, "%s link row should be gone after deleting its category", label)
}
execNoErr(t, db, `DELETE FROM software_categories WHERE id = ?`, teamARows[0].ID)
assertLinkGone(`SELECT software_category_id FROM software_installer_software_categories WHERE software_installer_id = ?`,
uint(installerA), "team A installer") //nolint:gosec // dismiss G115
execNoErr(t, db, `DELETE FROM software_categories WHERE id = ?`, teamBRows[2].ID)
assertLinkGone(`SELECT software_category_id FROM vpp_app_team_software_categories WHERE vpp_app_team_id = ?`,
uint(vppAppTeamB), "team B VPP app") //nolint:gosec // dismiss G115
execNoErr(t, db, `DELETE FROM software_categories WHERE id = ?`, teamARows[3].ID)
assertLinkGone(`SELECT software_category_id FROM in_house_app_software_categories WHERE in_house_app_id = ?`,
uint(inHouseAppA), "team A in-house app") //nolint:gosec // dismiss G115
// Unique key on (team_id, name) rejects duplicates within a team.
_, err := db.Exec(`INSERT INTO software_categories (name, team_id) VALUES (?, ?)`, "🌎 Browsers", uint(0))
require.Error(t, err, "duplicate (team_id, name) should violate unique key")
}
File diff suppressed because one or more lines are too long
+7
View File
@@ -6765,6 +6765,7 @@ func (ds *Datastore) GetSoftwareCategoryIDs(ctx context.Context, names []string)
if len(names) == 0 {
return []uint{}, nil
}
names = fleet.TranslateLegacySoftwareCategoryNames(names)
stmt := `SELECT id FROM software_categories WHERE name IN (?)`
stmt, args, err := sqlx.In(stmt, names)
@@ -6788,6 +6789,7 @@ func (ds *Datastore) GetSoftwareCategoryNameToIDMap(ctx context.Context, names [
if len(names) == 0 {
return map[string]uint{}, nil
}
names = fleet.TranslateLegacySoftwareCategoryNames(names)
stmt := `SELECT id, name FROM software_categories WHERE name IN (?)`
stmt, args, err := sqlx.In(stmt, names)
@@ -6804,6 +6806,11 @@ func (ds *Datastore) GetSoftwareCategoryNameToIDMap(ctx context.Context, names [
for _, cat := range categories {
result[cat.Name] = cat.ID
}
for plain, emoji := range fleet.LegacySoftwareCategoryNames {
if id, ok := result[emoji]; ok {
result[plain] = id
}
}
return result, nil
}
+24
View File
@@ -858,3 +858,27 @@ type SoftwareCategory struct {
ID uint `db:"id"`
Name string `db:"name"`
}
// Map the old default category names that don't include emojis to the new ones
// that are stored in the database with emojis. This is required to not break
// existing FMA manifests and GitOps files.
var LegacySoftwareCategoryNames = map[string]string{
"Browsers": "🌎 Browsers",
"Communication": "👬 Communication",
"Developer tools": "🧰 Developer tools",
"Productivity": "💻 Productivity",
"Security": "🔐 Security",
"Utilities": "🛟 Support",
}
func TranslateLegacySoftwareCategoryNames(names []string) []string {
out := make([]string, len(names))
for i, n := range names {
if newName, ok := LegacySoftwareCategoryNames[n]; ok {
out[i] = newName
} else {
out[i] = n
}
}
return out
}
@@ -150,6 +150,7 @@ func LoadSchema(t testing.TB, testName string, opts *DatastoreTestOptions, schem
"docker", "compose", "exec", "-T", "mysql_test",
// Command run inside container
"mysql",
"--default-character-set=utf8mb4",
"-u"+TestUsername, "-p"+TestPassword,
)
cmd.Stdin = strings.NewReader(sqlCommands)
@@ -171,6 +172,7 @@ func LoadSchema(t testing.TB, testName string, opts *DatastoreTestOptions, schem
"docker", "compose", "exec", "-T", "mysql_replica_test",
// Command run inside container
"mysql",
"--default-character-set=utf8mb4",
"-u"+TestUsername, "-p"+TestPassword,
)
cmd.Stdin = strings.NewReader(sqlCommands)
+12 -12
View File
@@ -13384,7 +13384,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD
s.DoRawWithHeaders("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package", titleID), body.Bytes(), http.StatusOK, headers)
expectedPayload := *payload
expectedPayload.Categories = []string{"Browsers", "Productivity"}
expectedPayload.Categories = []string{"🌎 Browsers", "💻 Productivity"}
expectedPayload.SelfService = true
checkSoftwareInstaller(t, s.ds, payload)
@@ -13401,7 +13401,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD
})
s.DoRawWithHeaders("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package", titleID), body.Bytes(), http.StatusOK, headers)
expectedPayload.Categories = []string{"Browsers"}
expectedPayload.Categories = []string{"🌎 Browsers"}
checkSoftwareInstaller(t, s.ds, payload)
meta, err = s.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(context.Background(), nil, titleID, false)
@@ -20861,7 +20861,7 @@ func (s *integrationEnterpriseTestSuite) TestMaintainedApps() {
titleResponse := getSoftwareTitleResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/software/titles/%d", title.ID), nil, http.StatusOK, &titleResponse, "team_id", "0")
require.NotNil(t, titleResponse.SoftwareTitle.SoftwarePackage)
require.Equal(t, []string{"Productivity"}, titleResponse.SoftwareTitle.SoftwarePackage.Categories)
require.Equal(t, []string{"💻 Productivity"}, titleResponse.SoftwareTitle.SoftwarePackage.Categories)
i, err = s.ds.GetSoftwareInstallerMetadataByID(context.Background(), getSoftwareInstallerIDByMAppID(4))
require.NoError(t, err)
@@ -22283,27 +22283,27 @@ func (s *integrationEnterpriseTestSuite) TestBatchSoftwareInstallerAndFMACategor
}{
{
desc: "duplicate categories provided",
categories: []string{"Developer tools", "Browsers", "Browsers"},
categories: []string{"🧰 Developer tools", "🌎 Browsers", "🌎 Browsers"},
},
{
desc: "valid categories 1",
categories: []string{"Developer tools", "Browsers"},
categories: []string{"🧰 Developer tools", "🌎 Browsers"},
},
{
desc: "valid categories 2",
categories: []string{"Communication", "Productivity"},
categories: []string{"👬 Communication", "💻 Productivity"},
},
{
desc: "valid categories 3 - Security and Utilities",
categories: []string{"Security", "Utilities"},
desc: "valid categories 3 - Security and Support",
categories: []string{"🔐 Security", "🛟 Support"},
},
{
desc: "valid categories 4 - mixed with new categories",
categories: []string{"Security", "Developer tools", "Utilities"},
categories: []string{"🔐 Security", "🧰 Developer tools", "🛟 Support"},
},
{
desc: "empty categories",
fmaDefaultCategories: []string{"Productivity"},
fmaDefaultCategories: []string{"💻 Productivity"},
},
}
for _, tc := range testCases {
@@ -26301,7 +26301,7 @@ func (s *integrationEnterpriseTestSuite) TestInHouseAppCRUD() {
s.DoRawWithHeaders("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package", titleID), body.Bytes(), http.StatusOK, headers)
expectedPayload := *payload
expectedPayload.LabelsExcludeAny = []string{labelResp.Label.Name}
expectedPayload.Categories = []string{"Productivity", "Browsers"}
expectedPayload.Categories = []string{"💻 Productivity", "🌎 Browsers"}
meta, err := s.ds.GetInHouseAppMetadataByTeamAndTitleID(context.Background(), &createTeamResp.Team.ID, installerID)
require.NoError(t, err)
@@ -26316,7 +26316,7 @@ func (s *integrationEnterpriseTestSuite) TestInHouseAppCRUD() {
"categories": {"Browsers"},
})
s.DoRawWithHeaders("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package", titleID), body.Bytes(), http.StatusOK, headers)
expectedPayload.Categories = []string{"Browsers"}
expectedPayload.Categories = []string{"🌎 Browsers"}
meta, err = s.ds.GetInHouseAppMetadataByTeamAndTitleID(context.Background(), &createTeamResp.Team.ID, installerID)
require.NoError(t, err)
+3 -3
View File
@@ -12815,7 +12815,7 @@ func (s *integrationMDMTestSuite) TestBatchAssociateAppStoreApps() {
if st.AppStoreApp.AppStoreID == s.appleVPPConfigSrvConfig.Assets[1].AdamID {
var getSWTitle getSoftwareTitleResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", st.ID), nil, http.StatusOK, &getSWTitle, "team_id", fmt.Sprint(tmGood.ID))
s.Assert().ElementsMatch([]string{"Browsers"}, getSWTitle.SoftwareTitle.AppStoreApp.Categories)
s.ElementsMatch([]string{"🌎 Browsers"}, getSWTitle.SoftwareTitle.AppStoreApp.Categories)
var labelNames []string
for _, l := range getSWTitle.SoftwareTitle.AppStoreApp.LabelsIncludeAll {
labelNames = append(labelNames, l.LabelName)
@@ -20965,7 +20965,7 @@ func (s *integrationMDMTestSuite) TestSoftwareCategories() {
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/software/titles/%d", vppAppTitleID), nil, http.StatusOK, &titleResponse, "team_id", "0")
require.NotNil(t, titleResponse.SoftwareTitle.AppStoreApp)
require.ElementsMatch(t, []string{"Developer tools", "Communication"}, titleResponse.SoftwareTitle.AppStoreApp.Categories)
require.ElementsMatch(t, []string{"🧰 Developer tools", "👬 Communication"}, titleResponse.SoftwareTitle.AppStoreApp.Categories)
// test GitOps with Security and Utilities categories
s.DoJSON("POST",
@@ -20979,7 +20979,7 @@ func (s *integrationMDMTestSuite) TestSoftwareCategories() {
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/software/titles/%d", vppAppTitleID), nil, http.StatusOK, &titleResponse, "team_id", "0")
require.NotNil(t, titleResponse.SoftwareTitle.AppStoreApp)
require.ElementsMatch(t, []string{"Security", "Utilities"}, titleResponse.SoftwareTitle.AppStoreApp.Categories)
require.ElementsMatch(t, []string{"🔐 Security", "🛟 Support"}, titleResponse.SoftwareTitle.AppStoreApp.Categories)
// empty out categories via gitops
s.DoJSON("POST",
@@ -204,7 +204,7 @@ func (s *integrationMDMTestSuite) TestSoftwareTitleDisplayNames() {
s.Assert().Empty(stResp.SoftwareTitle.DisplayName)
// PATCH semantics, so we shouldn't overwrite self service
s.Assert().True(stResp.SoftwareTitle.SoftwarePackage.SelfService)
s.Assert().ElementsMatch([]string{"Developer tools", "Browsers"}, stResp.SoftwareTitle.SoftwarePackage.Categories)
s.ElementsMatch([]string{"🧰 Developer tools", "🌎 Browsers"}, stResp.SoftwareTitle.SoftwarePackage.Categories)
// List software titles display name is empty
s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{}, http.StatusOK, &resp, "team_id", fmt.Sprint(team.ID))
@@ -387,7 +387,7 @@ func (s *integrationMDMTestSuite) TestSoftwareTitleDisplayNames() {
s.Assert().Empty(stResp.SoftwareTitle.DisplayName)
// PATCH semantics, so we shouldn't overwrite self service or categories or labels
s.Assert().True(stResp.SoftwareTitle.AppStoreApp.SelfService)
s.Assert().ElementsMatch([]string{"Developer tools", "Browsers"}, stResp.SoftwareTitle.AppStoreApp.Categories)
s.ElementsMatch([]string{"🧰 Developer tools", "🌎 Browsers"}, stResp.SoftwareTitle.AppStoreApp.Categories)
s.Assert().ElementsMatch([]string{lbl1Name, lbl2Name}, func() []string {
var ret []string
for _, l := range stResp.SoftwareTitle.AppStoreApp.LabelsIncludeAny {
@@ -491,7 +491,7 @@ func (s *integrationMDMTestSuite) TestSoftwareTitleDisplayNames() {
s.Assert().Equal("InHouseAppUpdate2", stResp.SoftwareTitle.DisplayName)
// PATCH semantics, so we shouldn't overwrite self service or categories
s.Assert().True(stResp.SoftwareTitle.SoftwarePackage.SelfService)
s.Assert().ElementsMatch([]string{"Developer tools", "Browsers"}, stResp.SoftwareTitle.SoftwarePackage.Categories)
s.ElementsMatch([]string{"🧰 Developer tools", "🌎 Browsers"}, stResp.SoftwareTitle.SoftwarePackage.Categories)
// List software titles has display name
s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{}, http.StatusOK, &resp, "team_id", fmt.Sprint(team.ID))