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:
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -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{}
|
||||
|
||||
Reference in New Issue
Block a user