diff --git a/changes/26375-fixes-duplicates-when-listing-software-titles b/changes/26375-fixes-duplicates-when-listing-software-titles new file mode 100644 index 0000000000..8effd5f842 --- /dev/null +++ b/changes/26375-fixes-duplicates-when-listing-software-titles @@ -0,0 +1 @@ +* Fixed bug when listing software titles for 'All teams' which caused duplicated entries. \ No newline at end of file diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tsx b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tsx index caecfe1de3..2d5b475400 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tsx @@ -293,6 +293,7 @@ const SoftwareTable = ({ value={softwareFilter} className={`${baseClass}__software-filter`} options={SOFTWARE_TITLES_DROPDOWN_OPTIONS} + isDisabled={teamId === undefined} onChange={(newValue: SingleValue) => newValue && handleCustomFilterDropdownChange( diff --git a/server/datastore/mysql/software_titles.go b/server/datastore/mysql/software_titles.go index 25e46269f3..8251eb16c8 100644 --- a/server/datastore/mysql/software_titles.go +++ b/server/datastore/mysql/software_titles.go @@ -1,11 +1,13 @@ package mysql import ( + "bytes" "context" "database/sql" "fmt" "slices" "strings" + "text/template" "time" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" @@ -111,7 +113,10 @@ func (ds *Datastore) ListSoftwareTitles( } dbReader := ds.reader(ctx) - getTitlesStmt, args := selectSoftwareTitlesSQL(opt) + getTitlesStmt, args, err := selectSoftwareTitlesSQL(opt) + if err != nil { + return nil, 0, nil, ctxerr.Wrap(ctx, err, "building software titles select statement") + } // build the count statement before adding the pagination constraints to `getTitlesStmt` getTitlesCountStmt := fmt.Sprintf(`SELECT COUNT(DISTINCT s.id) FROM (%s) AS s`, getTitlesStmt) @@ -306,149 +311,166 @@ func spliceSecondaryOrderBySoftwareTitlesSQL(stmt string, opts fleet.ListOptions return strings.Replace(stmt, targetSubstr, targetSubstr+secondaryOrderBy, 1) } -func selectSoftwareTitlesSQL(opt fleet.SoftwareTitleListOptions) (string, []any) { +func selectSoftwareTitlesSQL(opt fleet.SoftwareTitleListOptions) (string, []any, error) { stmt := ` SELECT - st.id, - st.name, - st.source, - st.browser, - st.bundle_identifier, - MAX(COALESCE(sthc.hosts_count, 0)) as hosts_count, - MAX(COALESCE(sthc.updated_at, date('0001-01-01 00:00:00'))) as counts_updated_at, - si.self_service as package_self_service, - si.filename as package_name, - si.version as package_version, - si.platform as package_platform, - si.url AS package_url, - si.install_during_setup as package_install_during_setup, - si.storage_id as package_storage_id, - si.fleet_maintained_app_id, - vat.self_service as vpp_app_self_service, - vat.adam_id as vpp_app_adam_id, - vat.install_during_setup as vpp_install_during_setup, - vap.latest_version as vpp_app_version, - vap.platform as vpp_app_platform, - vap.icon_url as vpp_app_icon_url + st.id + ,st.name + ,st.source + ,st.browser + ,st.bundle_identifier + ,MAX(COALESCE(sthc.hosts_count, 0)) as hosts_count + ,MAX(COALESCE(sthc.updated_at, date('0001-01-01 00:00:00'))) as counts_updated_at + {{if hasTeamID .}} + ,si.self_service as package_self_service + ,si.filename as package_name + ,si.version as package_version + ,si.platform as package_platform + ,si.url AS package_url + ,si.install_during_setup as package_install_during_setup + ,si.storage_id as package_storage_id + ,si.fleet_maintained_app_id + ,vat.self_service as vpp_app_self_service + ,vat.adam_id as vpp_app_adam_id + ,vat.install_during_setup as vpp_install_during_setup + ,vap.latest_version as vpp_app_version + ,vap.platform as vpp_app_platform + ,vap.icon_url as vpp_app_icon_url + {{end}} FROM software_titles st -LEFT JOIN software_installers si ON si.title_id = st.id AND %s -LEFT JOIN vpp_apps vap ON vap.title_id = st.id AND %s -LEFT JOIN vpp_apps_teams vat ON vat.adam_id = vap.adam_id AND vat.platform = vap.platform AND %s -LEFT JOIN software_titles_host_counts sthc ON sthc.software_title_id = st.id AND (%s) --- placeholder for JOIN on software/software_cve -%s --- placeholder for optional extra WHERE filter -WHERE %s --- placeholder for filter based on software installed on hosts + software installers -AND (%s) -GROUP BY st.id, package_self_service, package_name, package_version, package_platform, package_url, package_install_during_setup, package_storage_id, fleet_maintained_app_id, vpp_app_self_service, vpp_app_adam_id, vpp_app_version, vpp_app_platform, vpp_app_icon_url, vpp_install_during_setup` - - cveJoinType := "LEFT" - if opt.VulnerableOnly { - cveJoinType = "INNER" - } - - countsJoin := "TRUE" - softwareInstallersJoinCond := "TRUE" - vppAppsJoinCond := "TRUE" - vppAppsTeamsJoinCond := "TRUE" - includeVPPAppsAndSoftwareInstallers := "TRUE" - switch { - case opt.TeamID == nil: - countsJoin = "sthc.team_id = 0 AND sthc.global_stats = 1" - // When opt.TeamID is nil (aka "All teams") we do not include VPP-apps/installers - // that are not installed on any host. - includeVPPAppsAndSoftwareInstallers = "FALSE" - case *opt.TeamID == 0: - countsJoin = "sthc.team_id = 0 AND sthc.global_stats = 0" - softwareInstallersJoinCond = fmt.Sprintf("si.global_or_team_id = %d", *opt.TeamID) - vppAppsTeamsJoinCond = fmt.Sprintf("vat.global_or_team_id = %d", *opt.TeamID) - case *opt.TeamID > 0: - countsJoin = fmt.Sprintf("sthc.team_id = %d AND sthc.global_stats = 0", *opt.TeamID) - softwareInstallersJoinCond = fmt.Sprintf("si.global_or_team_id = %d", *opt.TeamID) - vppAppsTeamsJoinCond = fmt.Sprintf("vat.global_or_team_id = %d", *opt.TeamID) - } - - if opt.PackagesOnly { - vppAppsJoinCond = "FALSE" - vppAppsTeamsJoinCond = "FALSE" - } - - additionalWhere := "TRUE" - match := opt.ListOptions.MatchQuery - softwareJoin := "" - if match != "" || opt.VulnerableOnly { - // if we do a match but not vulnerable only, we want a LEFT JOIN on - // software because software installers may not have entries in software - // for their software title. If we do want vulnerable only, then we have to - // INNER JOIN because a CVE implies a specific software version. - softwareJoin = fmt.Sprintf(` - %s JOIN software s ON s.title_id = st.id - -- placeholder for changing the JOIN type to filter vulnerable software - %[1]s JOIN software_cve scve ON s.id = scve.software_id - `, cveJoinType) - } - + {{if hasTeamID .}} + LEFT JOIN software_installers si ON si.title_id = st.id AND si.global_or_team_id = {{teamID .}} + LEFT JOIN vpp_apps vap ON vap.title_id = st.id AND {{yesNo .PackagesOnly "FALSE" "TRUE"}} + LEFT JOIN vpp_apps_teams vat ON vat.adam_id = vap.adam_id AND vat.platform = vap.platform AND + {{if .PackagesOnly}} FALSE {{else}} vat.global_or_team_id = {{teamID .}}{{end}} + {{end}} + LEFT JOIN software_titles_host_counts sthc ON sthc.software_title_id = st.id AND + (sthc.team_id = {{teamID .}} AND sthc.global_stats = {{if hasTeamID .}} 0 {{else}} 1 {{end}}) +{{with $softwareJoin := " "}} + {{if or $.ListOptions.MatchQuery $.VulnerableOnly}} + -- If we do a match but not vulnerable only, we want a LEFT JOIN on + -- software because software installers may not have entries in software + -- for their software title. If we do want vulnerable only, then we have to + -- INNER JOIN because a CVE implies a specific software version. + {{$cveJoin := yesNo $.VulnerableOnly "INNER" "LEFT"}} + {{$softwareJoin = printf "%s JOIN software s ON s.title_id = st.id %[1]s JOIN software_cve scve ON s.id = scve.software_id" $cveJoin }} + {{end}} + {{if and $.VulnerableOnly (or $.KnownExploit $.MinimumCVSS $.MaximumCVSS)}} + {{$softwareJoin = printf "%s INNER JOIN cve_meta cm ON scve.cve = cm.cve" $softwareJoin}} + {{if $.KnownExploit}} + {{$softwareJoin = printf "%s AND cm.cisa_known_exploit = 1" $softwareJoin}} + {{end}} + {{if $.MinimumCVSS}} + {{$softwareJoin = printf "%s AND cm.cvss_score >= ?" $softwareJoin}} + {{end}} + {{if $.MaximumCVSS}} + {{$softwareJoin = printf "%s AND cm.cvss_score <= ?" $softwareJoin}} + {{end}} + {{end}} + {{$softwareJoin}} +{{end}} +WHERE + {{with $additionalWhere := "TRUE"}} + {{if $.ListOptions.MatchQuery}} + {{$additionalWhere = "(st.name LIKE ? OR scve.cve LIKE ?)"}} + {{end}} + {{if and (hasTeamID $) $.Platform}} + {{$postfix := printf " AND (si.platform IN (%s) OR vap.platform IN (%[1]s))" (placeholders $.Platform)}} + {{$additionalWhere = printf "%s %s" $additionalWhere $postfix}} + {{end}} + {{$additionalWhere}} + {{end}} + -- If teamID is set, defaults to "a software installer or VPP app exists", and see next condition. + {{with $defFilter := yesNo (hasTeamID .) "(si.id IS NOT NULL OR vat.adam_id IS NOT NULL)" "FALSE"}} + -- add software installed for hosts if we're not filtering for "available for install" only + {{if not $.AvailableForInstall}} + {{$defFilter = $defFilter | printf " ( %s OR sthc.hosts_count > 0 ) "}} + {{ end }} + {{if and $.SelfServiceOnly (hasTeamID $)}} + {{$defFilter = $defFilter | printf "%s AND ( si.self_service = 1 OR vat.self_service = 1 ) "}} + {{end}} + AND ({{$defFilter}}) + {{end}} +GROUP BY + st.id + {{if hasTeamID .}} + ,package_self_service + ,package_name + ,package_version + ,package_platform + ,package_url + ,package_install_during_setup + ,package_storage_id + ,fleet_maintained_app_id + ,vpp_app_self_service + ,vpp_app_adam_id + ,vpp_app_version + ,vpp_app_platform + ,vpp_app_icon_url + ,vpp_install_during_setup + {{end}} +` var args []any if opt.VulnerableOnly && (opt.KnownExploit || opt.MinimumCVSS > 0 || opt.MaximumCVSS > 0) { - softwareJoin += ` - INNER JOIN cve_meta cm ON scve.cve = cm.cve - ` - if opt.KnownExploit { - softwareJoin += ` - AND cm.cisa_known_exploit = 1 - ` - } if opt.MinimumCVSS > 0 { - softwareJoin += ` - AND cm.cvss_score >= ? - ` args = append(args, opt.MinimumCVSS) } if opt.MaximumCVSS > 0 { - softwareJoin += ` - AND cm.cvss_score <= ? - ` args = append(args, opt.MaximumCVSS) } } - if match != "" { - additionalWhere = " (st.name LIKE ? OR scve.cve LIKE ?)" - match = likePattern(match) + if opt.ListOptions.MatchQuery != "" { + match := likePattern(opt.ListOptions.MatchQuery) args = append(args, match, match) } if opt.Platform != "" { platforms := strings.Split(strings.ReplaceAll(opt.Platform, "macos", "darwin"), ",") platformPlaceholders := strings.TrimSuffix(strings.Repeat("?,", len(platforms)), ",") - - additionalWhere += fmt.Sprintf(` AND (si.platform IN (%s) OR vap.platform IN (%s))`, platformPlaceholders, platformPlaceholders) args = slices.Grow(args, len(platformPlaceholders)*2) - for _, platform := range platforms { // for software installers + // for software installers + for _, platform := range platforms { args = append(args, platform) } - for _, platform := range platforms { // for VPP apps; could micro-optimize later by dropping non-Apple platforms + // for VPP apps; could micro-optimize later by dropping non-Apple platforms + for _, platform := range platforms { args = append(args, platform) } } - // default to "a software installer or VPP app exists", and see next condition. - defaultFilter := fmt.Sprintf(` - ((si.id IS NOT NULL OR vat.adam_id IS NOT NULL) AND %s) - `, includeVPPAppsAndSoftwareInstallers) - - // add software installed for hosts if we're not filtering for "available for install" only - if !opt.AvailableForInstall { - defaultFilter = ` ( ` + defaultFilter + ` OR sthc.hosts_count > 0 ) ` - } - if opt.SelfServiceOnly { - defaultFilter += ` AND ( si.self_service = 1 OR vat.self_service = 1 ) ` + t, err := template.New("stm").Funcs(map[string]any{ + "yesNo": func(b bool, yes string, no string) string { + if b { + return yes + } + return no + }, + "placeholders": func(val string) string { + vals := strings.Split(val, ",") + return strings.TrimSuffix(strings.Repeat("?,", len(vals)), ",") + }, + "hasTeamID": func(q fleet.SoftwareTitleListOptions) bool { + return q.TeamID != nil + }, + "teamID": func(q fleet.SoftwareTitleListOptions) uint { + if q.TeamID == nil { + return 0 + } + return *q.TeamID + }, + }).Parse(stmt) + if err != nil { + return "", nil, err } - stmt = fmt.Sprintf(stmt, softwareInstallersJoinCond, vppAppsJoinCond, vppAppsTeamsJoinCond, countsJoin, softwareJoin, additionalWhere, defaultFilter) - return stmt, args + var buff bytes.Buffer + if err = t.Execute(&buff, opt); err != nil { + return "", nil, err + } + + return buff.String(), args, nil } func (ds *Datastore) selectSoftwareVersionsSQL(titleIDs []uint, teamID *uint, tmFilter fleet.TeamFilter, withCounts bool) ( diff --git a/server/datastore/mysql/software_titles_test.go b/server/datastore/mysql/software_titles_test.go index 5ca78cdb4e..1ddc6bf810 100644 --- a/server/datastore/mysql/software_titles_test.go +++ b/server/datastore/mysql/software_titles_test.go @@ -1,10 +1,16 @@ package mysql import ( + "compress/gzip" "context" "database/sql" + "encoding/json" "fmt" + "os" + "path/filepath" + "reflect" "sort" + "strings" "testing" "time" @@ -34,6 +40,7 @@ func TestSoftwareTitles(t *testing.T) { {"UploadedSoftwareExists", testUploadedSoftwareExists}, {"ListSoftwareTitlesVulnerabilityFilters", testListSoftwareTitlesVulnerabilityFilters}, {"UpdateSoftwareTitleName", testUpdateSoftwareTitleName}, + {"ListSoftwareTitlesDoesnotIncludeDuplicates", testListSoftwareTitlesDoesnotIncludeDuplicates}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -1792,3 +1799,216 @@ func testUpdateSoftwareTitleName(t *testing.T, ds *Datastore) { require.NoError(t, err) require.Equal(t, "installer2", title2.Name) } + +func testListSoftwareTitlesDoesnotIncludeDuplicates(t *testing.T, ds *Datastore) { + ctx := context.Background() + + host := test.NewHost(t, ds, "host1", "1", "host1key", "host1uuid", time.Now()) + + _, err := ds.UpdateHostSoftware(ctx, host.ID, []fleet.Software{ + {Name: "Santa", Version: "2025.4", Source: "apps", BundleIdentifier: "com.northpolesec.santa"}, + }) + require.NoError(t, err) + + var sw []fleet.Software + err = ds.writer(ctx).SelectContext(ctx, &sw, + `SELECT id, name, version, bundle_identifier, source, browser, title_id FROM software ORDER BY name, source, browser, version`) + require.NoError(t, err) + require.Len(t, sw, 1) + require.NotNil(t, sw[0].TitleID) + + user := test.NewUser(t, ds, "Alice", "alice@example.com", true) + tfr1, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir) + require.NoError(t, err) + + // same bundle identifier, different name + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 1"}) + require.NoError(t, err) + _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallerFile: tfr1, + BundleIdentifier: "com.northpolesec.santa", + Title: "Santa", + Version: "2025.2", + Extension: "pkg", + StorageID: "storage0", + Filename: "santa123", + Source: "pkg_packages", + UserID: user.ID, + TeamID: &team1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + }) + require.NoError(t, err) + + team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 2"}) + require.NoError(t, err) + _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallerFile: tfr1, + BundleIdentifier: "com.northpolesec.santa", + Title: "Santa", + Version: "2025.3", + Extension: "pkg", + StorageID: "storage0", + Filename: "santa123", + Source: "pkg_packages", + UserID: user.ID, + TeamID: &team2.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + }) + require.NoError(t, err) + + // We should only have a single title on the DB ... + var swt []fleet.SoftwareTitle + err = ds.writer(ctx).SelectContext(ctx, &swt, + `SELECT id, name, bundle_identifier, source, browser FROM software_titles ORDER BY name, source, browser`) + require.NoError(t, err) + require.Len(t, swt, 1) + + require.NoError(t, ds.SyncHostsSoftware(ctx, time.Now())) + require.NoError(t, ds.ReconcileSoftwareTitles(ctx)) + require.NoError(t, ds.SyncHostsSoftwareTitles(ctx, time.Now())) + + titles, _, _, err := ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{ + OrderKey: "name", + OrderDirection: fleet.OrderAscending, + }, + }, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) + require.NoError(t, err) + // We should have a single software title since when specifying 'All Teams' (TeamID = nil). + // installers are excluded + require.Len(t, titles, 1) +} + +func TestSelectSoftwareTitlesSQLGeneration(t *testing.T) { + fixturePath := filepath.Join("testdata", "select_software_titles_sql_fixture.gz") + + testData := []struct { + Args []any + Opts fleet.SoftwareTitleListOptions + Fingerprint string + }{} + + file, err := os.Open(fixturePath) + require.NoError(t, err) + defer file.Close() + + gzipReader, err := gzip.NewReader(file) + require.NoError(t, err) + defer gzipReader.Close() + + decoder := json.NewDecoder(gzipReader) + err = decoder.Decode(&testData) + require.NoError(t, err) + + for _, tt := range testData { + stm, args, err := selectSoftwareTitlesSQL(tt.Opts) + require.NoError(t, err) + require.Equal(t, tt.Fingerprint, NormalizeSQL(stm), tt.Opts) + require.Equal(t, tt.Args, args) + } +} + +// Use this to generate the select_software_titles_sql_fixture.gz testdata fixture. +// It generates a bunch of SoftwareTitleListOptions combinations, all SQL statements +// generated are normalized and written to the fixture file. +func generateSelectSoftwareTitlesSQLFixture(t *testing.T) { //nolint: unused + queryParams := struct { + Match []string + Platforms []string + VulnerableOnly []bool + AvailableForInstall []bool + SelfService []bool + KnownExploit []bool + MinVCSScores []float64 + MaxVCSScores []float64 + PackagesOnly []bool + TeamIDs []*uint + }{ + Match: []string{"", "chrome"}, + Platforms: []string{"", "darwin,linux"}, + VulnerableOnly: []bool{true, false}, + AvailableForInstall: []bool{true, false}, + SelfService: []bool{true, false}, + KnownExploit: []bool{true, false}, + MinVCSScores: []float64{0, 5.0}, + MaxVCSScores: []float64{0, 5.0}, + PackagesOnly: []bool{true, false}, + TeamIDs: []*uint{nil, ptr.Uint(0), ptr.Uint(1)}, + } + combinations := make([]fleet.SoftwareTitleListOptions, 0) + currentValues := make(map[string]interface{}) + + generateSoftwareTitleListOptionsCombinations( + reflect.ValueOf(queryParams), + currentValues, + &combinations, + ) + + testData := []struct { + Args []any + Opts fleet.SoftwareTitleListOptions + Fingerprint string + }{} + + for _, c := range combinations { + sqlStm, args, err := selectSoftwareTitlesSQL(c) + testData = append(testData, struct { + Args []any + Opts fleet.SoftwareTitleListOptions + Fingerprint string + }{Args: args, Opts: c, Fingerprint: NormalizeSQL(sqlStm)}) + require.NoError(t, err) + } + + asJSON, err := json.Marshal(testData) + require.NoError(t, err) + + fPath := filepath.Join("testdata", "select_software_titles_sql_fixture.gz") + + file, err := os.Create(fPath) + require.NoError(t, err) + defer file.Close() + + gzipWriter := gzip.NewWriter(file) + defer gzipWriter.Close() + + _, err = gzipWriter.Write(asJSON) + require.NoError(t, err) +} + +// nolint: unused +func generateSoftwareTitleListOptionsCombinations( + v reflect.Value, + currentValues map[string]interface{}, + combinations *[]fleet.SoftwareTitleListOptions, +) { + t := v.Type() + if len(currentValues) == t.NumField() { + opt := &fleet.SoftwareTitleListOptions{ + TeamID: currentValues["TeamIDs"].(*uint), + Platform: currentValues["Platforms"].(string), + VulnerableOnly: currentValues["VulnerableOnly"].(bool), + PackagesOnly: currentValues["PackagesOnly"].(bool), + SelfServiceOnly: currentValues["SelfService"].(bool), + AvailableForInstall: currentValues["AvailableForInstall"].(bool), + MinimumCVSS: currentValues["MinVCSScores"].(float64), + MaximumCVSS: currentValues["MaxVCSScores"].(float64), + KnownExploit: currentValues["KnownExploit"].(bool), + ListOptions: fleet.ListOptions{ + MatchQuery: currentValues["Match"].(string), + }, + } + *combinations = append(*combinations, *opt) + return + } + + fieldIndex := len(currentValues) + field := t.Field(fieldIndex) + slice := v.Field(fieldIndex) + + for i := 0; i < slice.Len(); i++ { + currentValues[field.Name] = slice.Index(i).Interface() + generateSoftwareTitleListOptionsCombinations(v, currentValues, combinations) + } + delete(currentValues, field.Name) +} diff --git a/server/datastore/mysql/testdata/select_software_titles_sql_fixture.gz b/server/datastore/mysql/testdata/select_software_titles_sql_fixture.gz new file mode 100644 index 0000000000..aaad2d7228 Binary files /dev/null and b/server/datastore/mysql/testdata/select_software_titles_sql_fixture.gz differ diff --git a/server/datastore/mysql/testing_utils.go b/server/datastore/mysql/testing_utils.go index f58daa7713..e85796bb8d 100644 --- a/server/datastore/mysql/testing_utils.go +++ b/server/datastore/mysql/testing_utils.go @@ -16,6 +16,7 @@ import ( "os" "os/exec" "path" + "regexp" "runtime" "strconv" "strings" @@ -843,6 +844,47 @@ func (ds *Datastore) ReplicaStatus(ctx context.Context) (map[string]interface{}, return result, nil } +// NormalizeSQL normalizes the SQL statement by removing extra spaces and new lines, etc. +func NormalizeSQL(query string) string { + query = strings.ToUpper(query) + query = strings.TrimSpace(query) + + transformations := []struct { + pattern *regexp.Regexp + replacement string + }{ + { + // Remove comments + regexp.MustCompile(`(?m)--.*$|/\*(?s).*?\*/`), + "", + }, + { + // Normalize whitespace + regexp.MustCompile(`\s+`), + " ", + }, + { + // Replace spaces around ',' + regexp.MustCompile(`\s*,\s*`), + ",", + }, + { + // Replace extra spaces before ( + regexp.MustCompile(`\s*\(\s*`), + " (", + }, + { + // Replace extra spaces before ( + regexp.MustCompile(`\s*\)\s*`), + ") ", + }, + } + for _, tx := range transformations { + query = tx.pattern.ReplaceAllString(query, tx.replacement) + } + return query +} + func checkUpcomingActivities(t *testing.T, ds *Datastore, host *fleet.Host, execIDs ...string) { ctx := t.Context() diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 1451befb95..418c44395b 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -8440,11 +8440,13 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() { require.NoError(t, s.ds.SyncHostsSoftwareTitles(ctx, hostsCountTs)) var resp listSoftwareTitlesResponse - // no self-service software yet + // self-service flag is ignored if no team specified see https://github.com/fleetdm/fleet/issues/26375 s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{}, http.StatusOK, &resp, "self_service", "1") - require.Empty(t, resp.SoftwareTitles) + require.Equal(t, 2, resp.Count) + s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{}, http.StatusOK, &resp) require.Equal(t, 2, resp.Count) + require.NotEmpty(t, resp.CountsUpdatedAt) softwareTitleListResultsMatch([]fleet.SoftwareTitleListResult{ { @@ -9190,7 +9192,7 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() { require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage.SelfService) require.True(t, *resp.SoftwareTitles[0].SoftwarePackage.SelfService) - // "All teams" returns no software because the self-service software it's not installed (host_counts == 0). + // "All teams" returns all software regardless of self_service see https://github.com/fleetdm/fleet/issues/26375 resp = listSoftwareTitlesResponse{} s.DoJSON( "GET", "/api/latest/fleet/software/titles", @@ -9199,7 +9201,7 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() { "self_service", "true", ) - require.Empty(t, resp.SoftwareTitles, 0) + require.Equal(t, resp.Count, 2) // "No team" returns the emacs software resp = listSoftwareTitlesResponse{}