Removes duplicates when listing software titles on 'All teams' (#29459)

For #26375. 

When listing software titles for 'All teams', do not join against
software installers nor vpps to avoid duplicates.

Since filters related to software installers/VPP apps are no longer used
when viewing titles for 'All teams', the filter dropdown is disabled if
'All teams' is selected.
This commit is contained in:
Juan Fernandez
2025-06-07 10:47:53 -04:00
committed by GitHub
parent 1547210f1c
commit 1c7d2a0c8a
7 changed files with 407 additions and 119 deletions
@@ -0,0 +1 @@
* Fixed bug when listing software titles for 'All teams' which caused duplicated entries.
@@ -293,6 +293,7 @@ const SoftwareTable = ({
value={softwareFilter}
className={`${baseClass}__software-filter`}
options={SOFTWARE_TITLES_DROPDOWN_OPTIONS}
isDisabled={teamId === undefined}
onChange={(newValue: SingleValue<CustomOptionType>) =>
newValue &&
handleCustomFilterDropdownChange(
+137 -115
View File
@@ -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) (
@@ -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)
}
+42
View File
@@ -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()
@@ -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{}