diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareFilterInput.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareFilterInput.java index 12973114a..d6ad4c46d 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareFilterInput.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareFilterInput.java @@ -1,5 +1,6 @@ package com.openframe.api.dto.rmm.software; +import com.openframe.data.document.rmm.software.SoftwareAutoUpdateStatus; import lombok.Data; import java.util.List; @@ -14,4 +15,6 @@ public class SoftwareFilterInput { private SoftwareCveSeverity minSeverity; private List deviceTagIds; + + private List autoUpdateStatuses; } diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareFilters.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareFilters.java index 9fc7acba3..1beea6b4a 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareFilters.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareFilters.java @@ -13,7 +13,7 @@ public class SoftwareFilters { private List versionStatuses; private List severities; - // empty until the auto-update policy backs it: the schema field is non-null + // non-null in the schema; the tenant-wide facet stays empty until the read model backs it @Builder.Default private List autoUpdateStatuses = List.of(); diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceFilterInput.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceFilterInput.java index dafdac8fa..41872c4b9 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceFilterInput.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceFilterInput.java @@ -1,5 +1,7 @@ package com.openframe.api.dto.rmm.software; +import com.openframe.data.document.device.DeviceStatus; +import com.openframe.data.document.rmm.software.SoftwareAutoUpdateStatus; import lombok.Data; import java.util.List; @@ -9,4 +11,8 @@ public class SoftwareOnDeviceFilterInput { private List statuses; private List deviceTagIds; + private List organizationIds; + private List deviceStatuses; + private List installedBy; + private List autoUpdateStatuses; } diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceFilters.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceFilters.java index f6a89b931..25a56ac44 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceFilters.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceFilters.java @@ -11,7 +11,7 @@ public class SoftwareOnDeviceFilters { private List statuses; - // empty until the read model and the auto-update policy back them: the schema fields are non-null + // non-null in the schema; empty when no device has a value for them @Builder.Default private List installedBy = List.of(); @Builder.Default diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceResponse.java index 3e369e523..a8947ce0f 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceResponse.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceResponse.java @@ -12,5 +12,6 @@ public class SoftwareOnDeviceResponse { private Machine device; private String softwareVersion; private SoftwareOnDeviceStatus status; + private SoftwareSource installedBy; private SoftwareAutoUpdateStatus autoUpdate; } diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVulnerabilityFilterInput.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVulnerabilityFilterInput.java new file mode 100644 index 000000000..284c448b7 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVulnerabilityFilterInput.java @@ -0,0 +1,10 @@ +package com.openframe.api.dto.rmm.software; + +import lombok.Data; + +import java.util.List; + +@Data +public class SoftwareVulnerabilityFilterInput { + private List severities; +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInventoryService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInventoryService.java index 674ec420d..014a81a6b 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInventoryService.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInventoryService.java @@ -10,6 +10,7 @@ import com.openframe.api.dto.rmm.software.SoftwareOnDeviceStatus; import com.openframe.api.dto.rmm.software.SoftwareResponse; import com.openframe.api.dto.rmm.software.SoftwareSource; +import com.openframe.api.dto.rmm.software.SoftwareVulnerabilityFilterInput; import com.openframe.api.dto.rmm.software.SoftwareVulnerabilityResponse; import com.openframe.api.dto.rmm.software.SoftwareVulnerabilitySummaryResponse; import com.openframe.api.dto.shared.PageResult; @@ -25,7 +26,13 @@ import com.openframe.api.service.rmm.fleet.HostInventory.CveHit; import com.openframe.api.service.rmm.vulnerability.FleetGlobalVulnerabilityMapper; import com.openframe.api.util.FleetSoftwareMapper; +import com.openframe.api.util.SoftwareVersions; import com.openframe.api.util.FleetVulnerabilityMapper; +import com.openframe.data.document.organization.Organization; +import com.openframe.data.document.tag.TagAssignment; +import com.openframe.data.document.tag.TagEntityType; +import com.openframe.data.repository.organization.OrganizationRepository; +import com.openframe.data.repository.tag.TagAssignmentRepository; import com.openframe.data.document.device.Machine; import com.openframe.data.document.tool.IntegratedTool; import com.openframe.data.document.tool.IntegratedToolId; @@ -78,6 +85,7 @@ public class SoftwareInventoryService { private static final Set SORTABLE = Set.of("name", "devicesCount", "cveCount", "highestSeverity", "severity"); private static final String SORTABLE_FIELDS = "name, devicesCount, cveCount, highestSeverity"; private static final String DEFAULT_SORT_FIELD = "name"; + private static final String DEVICE_SORT_FIELD = "softwareVersion"; private static final int TITLES_FETCH_PAGE = 500; private static final int TITLES_FETCH_CAP = 5000; private static final Map FLEET_ORDER_KEY = Map.of( @@ -92,6 +100,8 @@ public class SoftwareInventoryService { private final TenantIdProvider tenantIdProvider; private final DeviceHostInventoryLoader deviceHostInventoryLoader; private final SoftwareAutoUpdateService autoUpdateService; + private final TagAssignmentRepository tagAssignmentRepository; + private final OrganizationRepository organizationRepository; @Value("${TENANT_ID:}") private String tenantIdEnv; @@ -309,7 +319,7 @@ private static int severityRank(SoftwareCveSeverity severity) { } public PageResult listVulnerabilitiesForSoftware( - String softwareId, String search, int page, Integer perPage, + String softwareId, SoftwareVulnerabilityFilterInput filter, String search, int page, Integer perPage, String sortField, boolean sortAsc) { Optional parsed = parseNumericId(softwareId); if (parsed.isEmpty()) { @@ -338,6 +348,7 @@ public PageResult listVulnerabilitiesForSoftware( .map(toRow) .filter(Objects::nonNull) .filter(v -> matchesSearch(v, search)) + .filter(v -> filter == null || selected(filter.getSeverities(), v.getSeverity())) .sorted(comparator(sortField, sortAsc)) .toList(); return paginate(all, page, perPage); @@ -345,20 +356,70 @@ public PageResult listVulnerabilitiesForSoftware( public PageResult listDevicesForSoftware(String softwareId, SoftwareOnDeviceFilterInput filter, - String search, int page, Integer perPage) { - List all = allDevicesForSoftware(softwareId, search).stream() - .filter(row -> matchesStatusFilter(row, filter)) + String search, int page, Integer perPage, + SortInput sort) { + Comparator order = deviceOrder(sort); + List rows = devicesWithAutoUpdate(softwareId, search); + Set tagged = taggedMachines(rows, filter); + List all = rows.stream() + .filter(row -> matchesDeviceFilter(row, filter, tagged)) + .sorted(order) .toList(); - PageResult result = paginateList(all, page, perPage); - autoUpdateService.applyToDevices(softwareId, result.items()); - return result; + return paginateList(all, page, perPage); } - private static boolean matchesStatusFilter(SoftwareOnDeviceResponse row, SoftwareOnDeviceFilterInput filter) { - if (filter == null || filter.getStatuses() == null || filter.getStatuses().isEmpty()) { + // the filters read auto-update, so it is set on every row, not only on the page + private List devicesWithAutoUpdate(String softwareId, String search) { + List rows = allDevicesForSoftware(softwareId, search); + autoUpdateService.applyToDevices(softwareId, rows); + return rows; + } + + private static Comparator deviceOrder(SortInput sort) { + if (!hasSortField(sort)) { + return (a, b) -> 0; + } + if (!DEVICE_SORT_FIELD.equals(sort.getField())) { + throw new BadRequestException("Unknown sort field '" + sort.getField() + "'. Sortable fields: " + DEVICE_SORT_FIELD); + } + Comparator versions = isDescending(sort) + ? Comparator.comparing(Function.identity(), SoftwareVersions::compare).reversed() + : SoftwareVersions::compare; + return Comparator.comparing(SoftwareOnDeviceResponse::getSoftwareVersion, Comparator.nullsLast(versions)); + } + + // null = no tag filter; a device matches when it carries any of the tags + private Set taggedMachines(List rows, SoftwareOnDeviceFilterInput filter) { + if (filter == null || isEmpty(filter.getDeviceTagIds())) { + return null; + } + List machineIds = rows.stream().map(row -> row.getDevice().getMachineId()).toList(); + if (machineIds.isEmpty()) { + return Set.of(); + } + return tagAssignmentRepository.findByEntityIdInAndTagIdInAndEntityType( + machineIds, filter.getDeviceTagIds(), TagEntityType.DEVICE).stream() + .map(TagAssignment::getEntityId) + .collect(Collectors.toSet()); + } + + private static boolean matchesDeviceFilter(SoftwareOnDeviceResponse row, SoftwareOnDeviceFilterInput filter, + Set tagged) { + if (filter == null) { return true; } - return row.getStatus() != null && filter.getStatuses().contains(row.getStatus()); + Machine device = row.getDevice(); + return selected(filter.getStatuses(), row.getStatus()) + && selected(filter.getOrganizationIds(), device.getOrganizationId()) + && selected(filter.getDeviceStatuses(), device.getStatus()) + && selected(filter.getInstalledBy(), row.getInstalledBy()) + && selected(filter.getAutoUpdateStatuses(), row.getAutoUpdate()) + && (tagged == null || tagged.contains(device.getMachineId())); + } + + // nothing chosen in a dropdown matches every row; a row without a value matches no choice + private static boolean selected(List chosen, T value) { + return isEmpty(chosen) || (value != null && chosen.contains(value)); } public PageResult listSoftwareForDevice(String machineId, SoftwareFilterInput filter, @@ -367,18 +428,21 @@ public PageResult listSoftwareForDevice(String machineId, Soft Comparator order = deviceSoftwareOrder(sort); HostInventory inventory = deviceHostInventoryLoader.load(fleet(), machineId); List rows = deviceSoftwareRows(inventory, filter, search); + // the filter reads auto-update, so it is set on every row, not only on the page + autoUpdateService.applyToSoftwareOnDevice(machineId, rows); + List chosen = rows.stream() + .filter(row -> filter == null || selected(filter.getAutoUpdateStatuses(), row.getAutoUpdate())) + .toList(); if (!fleetPaging) { - enrichDevicesCountFromHosts(rows); + enrichDevicesCountFromHosts(chosen); } - List ordered = rows.stream().sorted(order).toList(); - PageResult result = paginateList(ordered, page, perPage); - autoUpdateService.applyToSoftwareOnDevice(machineId, result.items()); - return result; + return paginateList(chosen.stream().sorted(order).toList(), page, perPage); } public SoftwareFilters getDeviceSoftwareFilters(String machineId, String search) { HostInventory inventory = deviceHostInventoryLoader.load(fleet(), machineId); List rows = deviceSoftwareRows(inventory, null, search); + autoUpdateService.applyToSoftwareOnDevice(machineId, rows); return softwareFilters(rows); } @@ -484,19 +548,35 @@ private static boolean matchesDeviceSoftwareSearch(SoftwareResponse row, String } public SoftwareOnDeviceFilters getSoftwareDeviceFilters(String softwareId, String search) { - Map counts = allDevicesForSoftware(softwareId, search).stream() - .map(SoftwareOnDeviceResponse::getStatus) + List rows = devicesWithAutoUpdate(softwareId, search); + return SoftwareOnDeviceFilters.builder() + .statuses(facet(rows, SoftwareOnDeviceResponse::getStatus)) + .installedBy(facet(rows, SoftwareOnDeviceResponse::getInstalledBy)) + .autoUpdateStatuses(facet(rows, SoftwareOnDeviceResponse::getAutoUpdate)) + .deviceStatuses(facet(rows, row -> row.getDevice().getStatus())) + .organizations(organizationFacet(rows)) + .build(); + } + + private List organizationFacet(List rows) { + Map counts = rows.stream() + .map(row -> row.getDevice().getOrganizationId()) .filter(Objects::nonNull) .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())); - List statuses = counts.entrySet().stream() - .sorted(Comparator.comparingInt(e -> e.getKey().ordinal())) + if (counts.isEmpty()) { + return List.of(); + } + Map names = organizationRepository.findByOrganizationIdIn(counts.keySet()).stream() + .filter(organization -> organization.getName() != null) + .collect(Collectors.toMap(Organization::getOrganizationId, Organization::getName, (a, b) -> a)); + return counts.entrySet().stream() .map(e -> SoftwareFilterOption.builder() - .value(e.getKey().name()) - .label(humanize(e.getKey().name())) + .value(e.getKey()) + .label(names.getOrDefault(e.getKey(), e.getKey())) .count(e.getValue().intValue()) .build()) + .sorted(Comparator.comparing(SoftwareFilterOption::getLabel, String.CASE_INSENSITIVE_ORDER)) .toList(); - return SoftwareOnDeviceFilters.builder().statuses(statuses).build(); } private List allDevicesForSoftware(String softwareId, String search) { @@ -509,6 +589,7 @@ private List allDevicesForSoftware(String softwareId, return List.of(); } String latestVersion = FleetSoftwareMapper.toResponse(title).getLatestVersion(); + SoftwareSource installedBy = installedBy(title.getSource()); List versions = title.getVersions().stream() .filter(version -> version.getId() != null) @@ -525,7 +606,7 @@ private List allDevicesForSoftware(String softwareId, // one machine can carry several versions (e.g. a pip per Python install) and several Fleet hosts Map> rowsByMachine = hostVersions.stream() - .map(hv -> toDeviceResponse(hv, machinesByHostId, latestVersion)) + .map(hv -> toDeviceResponse(hv, machinesByHostId, latestVersion, installedBy)) .filter(Objects::nonNull) .collect(Collectors.groupingBy(row -> row.getDevice().getMachineId(), java.util.LinkedHashMap::new, Collectors.toList())); @@ -541,9 +622,17 @@ private static SoftwareOnDeviceResponse mergeDeviceRows(List hostVersionsOf(SoftwareTitleVersion version) { HostSearchRequest request = new HostSearchRequest(); request.setSoftwareVersionId(version.getId()); @@ -554,7 +643,7 @@ private List hostVersionsOf(SoftwareTitleVersion version) { } private static SoftwareOnDeviceResponse toDeviceResponse(HostVersion hv, Map machinesByHostId, - String latestVersion) { + String latestVersion, SoftwareSource installedBy) { Machine device = hv.host().getId() == null ? null : machinesByHostId.get(hv.host().getId()); if (device == null) { return null; @@ -565,6 +654,7 @@ private static SoftwareOnDeviceResponse toDeviceResponse(HostVersion hv, Map titles) { .versionStatuses(facet(titles, SoftwareResponse::getVersionStatus)) .severities(facet(titles, row -> row.getVulnerabilitySummary() == null ? null : row.getVulnerabilitySummary().getHighestSeverity())) + .autoUpdateStatuses(facet(titles, SoftwareResponse::getAutoUpdate)) .build(); } - private static > List facet(List titles, - Function dimension) { - Map counts = titles.stream() + private static > List facet(List rows, Function dimension) { + Map counts = rows.stream() .map(dimension) .filter(Objects::nonNull) .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())); @@ -661,32 +751,10 @@ private static String joinVersions(List versions) { return versions.stream() .filter(Objects::nonNull) .distinct() - .sorted(SoftwareInventoryService::compareVersions) + .sorted(SoftwareVersions::compare) .collect(Collectors.joining(", ")); } - // Numeric, segment-wise version order so "9.0" precedes "10.0" (plain String sort would not). - private static int compareVersions(String a, String b) { - String[] pa = a.split("\\."); - String[] pb = b.split("\\."); - for (int i = 0; i < Math.max(pa.length, pb.length); i++) { - int va = i < pa.length ? parseSegment(pa[i]) : 0; - int vb = i < pb.length ? parseSegment(pb[i]) : 0; - if (va != vb) { - return Integer.compare(va, vb); - } - } - return a.compareTo(b); - } - - private static int parseSegment(String segment) { - try { - return Integer.parseInt(segment.trim()); - } catch (NumberFormatException e) { - return 0; - } - } - private Map enrichCves(Set cves) { // ConcurrentHashMap rejects null values, and Fleet returns null for a CVE it has no record of // (404) — collect only the resolved ones; callers null-coalesce a missing key. diff --git a/openframe-api-lib/src/main/java/com/openframe/api/util/FleetSoftwareMapper.java b/openframe-api-lib/src/main/java/com/openframe/api/util/FleetSoftwareMapper.java index aa543deee..0d310f5f5 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/util/FleetSoftwareMapper.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/util/FleetSoftwareMapper.java @@ -25,12 +25,22 @@ public static SoftwareResponse toResponse(SoftwareTitle title) { .name(title.getName()) .source(FleetSoftwareCategory.of(title.getSource()).source()) .currentVersion(current == null ? null : current.getVersion()) + .latestVersion(latestVersion(versions)) .olderVersionsCount(olderVersionsCount(versions)) .devicesCount(title.getHostsCount()) .vulnerabilitySummary(rollUpVulnerabilities(versions)) .build(); } + // the highest version installed on the tenant's devices: Fleet does not say what the newest release is + private static String latestVersion(List versions) { + return versions == null ? null : versions.stream() + .map(SoftwareTitleVersion::getVersion) + .filter(Objects::nonNull) + .max(SoftwareVersions::compare) + .orElse(null); + } + private static SoftwareTitleVersion pickCurrentVersion(List versions) { if (versions == null || versions.isEmpty()) { return null; diff --git a/openframe-api-lib/src/main/java/com/openframe/api/util/SoftwareVersions.java b/openframe-api-lib/src/main/java/com/openframe/api/util/SoftwareVersions.java new file mode 100644 index 000000000..7ec1a66ec --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/util/SoftwareVersions.java @@ -0,0 +1,32 @@ +package com.openframe.api.util; + +import lombok.experimental.UtilityClass; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@UtilityClass +public class SoftwareVersions { + + private static final Pattern LEADING_DIGITS = Pattern.compile("^\\d{1,18}"); + + // Numeric, segment-wise version order so "9.0" precedes "10.0" (plain String sort would not). + public static int compare(String a, String b) { + String[] pa = a.split("\\."); + String[] pb = b.split("\\."); + for (int i = 0; i < Math.max(pa.length, pb.length); i++) { + long va = i < pa.length ? parseSegment(pa[i]) : 0; + long vb = i < pb.length ? parseSegment(pb[i]) : 0; + if (va != vb) { + return Long.compare(va, vb); + } + } + return a.compareTo(b); + } + + // the leading number of a segment, so "3-beta" and "0_2" still count as 3 and 0 + private static long parseSegment(String segment) { + Matcher digits = LEADING_DIGITS.matcher(segment.trim()); + return digits.find() ? Long.parseLong(digits.group()) : 0; + } +} diff --git a/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareInventoryServiceFleetPagingTest.java b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareInventoryServiceFleetPagingTest.java index 368e170a3..d7b0f68ea 100644 --- a/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareInventoryServiceFleetPagingTest.java +++ b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareInventoryServiceFleetPagingTest.java @@ -14,6 +14,8 @@ import com.openframe.api.service.rmm.fleet.HostInventory; import com.openframe.data.document.device.Machine; import com.openframe.data.repository.tool.IntegratedToolRepository; +import com.openframe.data.repository.organization.OrganizationRepository; +import com.openframe.data.repository.tag.TagAssignmentRepository; import com.openframe.data.service.TenantIdProvider; import com.openframe.sdk.fleetmdm.FleetMdmClient; import com.openframe.sdk.fleetmdm.model.FleetSoftware; @@ -61,13 +63,15 @@ class SoftwareInventoryServiceFleetPagingTest { @Mock private IntegratedToolRepository integratedToolRepository; @Mock private DeviceHostInventoryLoader deviceHostInventoryLoader; @Mock private SoftwareAutoUpdateService autoUpdateService; + @Mock private TagAssignmentRepository tagAssignmentRepository; + @Mock private OrganizationRepository organizationRepository; private SoftwareInventoryService service; @BeforeEach void setUp() { service = new SoftwareInventoryService(integratedToolRepository, deviceCountEnricher, hostMachineResolver, - tenantIdProvider, deviceHostInventoryLoader, autoUpdateService); + tenantIdProvider, deviceHostInventoryLoader, autoUpdateService, tagAssignmentRepository, organizationRepository); ReflectionTestUtils.setField(service, "fleet", fleet); ReflectionTestUtils.setField(service, "fleetPaging", true); } @@ -151,7 +155,7 @@ void listVulnerabilitiesForSoftware_detailsPerVersion_earliestDiscoveryWins() { // execution PageResult result = - service.listVulnerabilitiesForSoftware("42", null, 0, 50, null, true); + service.listVulnerabilitiesForSoftware("42", null, null, 0, 50, null, true); // verifications assertThat(result.items()) @@ -171,7 +175,7 @@ void listVulnerabilitiesForSoftware_versionGoneFromFleet_rowWithoutDetails() { // execution PageResult result = - service.listVulnerabilitiesForSoftware("42", null, 0, 50, null, true); + service.listVulnerabilitiesForSoftware("42", null, null, 0, 50, null, true); // verifications assertThat(result.items()).extracting(SoftwareVulnerabilityResponse::getCveId, SoftwareVulnerabilityResponse::getDiscoveredAt) @@ -190,7 +194,7 @@ void listDevicesForSoftware_hostsOfEveryVersionCorrelated() { when(hostMachineResolver.resolve(eq("t1"), anyList())).thenReturn(Map.of(10L, machine("m-10"), 11L, machine("m-11"))); // execution - PageResult result = service.listDevicesForSoftware("42", null, null, 0, 50); + PageResult result = service.listDevicesForSoftware("42", null, null, 0, 50, null); // verifications assertThat(result.items()) diff --git a/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareInventoryServiceTest.java b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareInventoryServiceTest.java index c5c473d83..27253d482 100644 --- a/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareInventoryServiceTest.java +++ b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareInventoryServiceTest.java @@ -10,6 +10,7 @@ import com.openframe.api.dto.rmm.software.SoftwareOnDeviceStatus; import com.openframe.api.dto.rmm.software.SoftwareResponse; import com.openframe.api.dto.rmm.software.SoftwareSource; +import com.openframe.api.dto.rmm.software.SoftwareVulnerabilityFilterInput; import com.openframe.api.dto.rmm.software.SoftwareVulnerabilityResponse; import com.openframe.api.dto.shared.PageResult; import com.openframe.api.dto.shared.SortDirection; @@ -19,7 +20,14 @@ import com.openframe.api.service.rmm.fleet.FleetDeviceCountEnricher; import com.openframe.api.service.rmm.fleet.FleetHostMachineResolver; import com.openframe.api.service.rmm.fleet.HostInventory; +import com.openframe.data.document.device.DeviceStatus; import com.openframe.data.document.device.Machine; +import com.openframe.data.document.organization.Organization; +import com.openframe.data.document.rmm.software.SoftwareAutoUpdateStatus; +import com.openframe.data.document.tag.TagAssignment; +import com.openframe.data.document.tag.TagEntityType; +import com.openframe.data.repository.organization.OrganizationRepository; +import com.openframe.data.repository.tag.TagAssignmentRepository; import com.openframe.data.service.TenantIdProvider; import com.openframe.sdk.fleetmdm.FleetMdmClient; import com.openframe.sdk.fleetmdm.model.FleetSoftware; @@ -42,6 +50,7 @@ import java.util.List; import java.util.Map; +import java.util.Set; import java.util.function.Function; import java.util.stream.Stream; @@ -76,6 +85,8 @@ class SoftwareInventoryServiceTest { @Mock private com.openframe.data.repository.tool.IntegratedToolRepository integratedToolRepository; @Mock private DeviceHostInventoryLoader deviceHostInventoryLoader; @Mock private SoftwareAutoUpdateService autoUpdateService; + @Mock private TagAssignmentRepository tagAssignmentRepository; + @Mock private OrganizationRepository organizationRepository; @Captor private ArgumentCaptor> pageRowsCaptor; @Captor private ArgumentCaptor>> keysOfCaptor; @@ -85,7 +96,7 @@ class SoftwareInventoryServiceTest { @BeforeEach void setUp() { service = new SoftwareInventoryService(integratedToolRepository, deviceCountEnricher, hostMachineResolver, - tenantIdProvider, deviceHostInventoryLoader, autoUpdateService); + tenantIdProvider, deviceHostInventoryLoader, autoUpdateService, tagAssignmentRepository, organizationRepository); // Bypass @PostConstruct wireFleetClient — inject the mocked FleetMdmClient directly. ReflectionTestUtils.setField(service, "fleet", fleet); } @@ -93,7 +104,7 @@ void setUp() { @Test @DisplayName("listDevicesForSoftware: a non-numeric id short-circuits to empty without touching Fleet") void listDevices_nonNumericId_emptyNoFleet() { - assertThat(service.listDevicesForSoftware("not-a-number", null, null, 0, 50).items()).isEmpty(); + assertThat(service.listDevicesForSoftware("not-a-number", null, null, 0, 50, null).items()).isEmpty(); verifyNoInteractions(fleet, hostMachineResolver); } @@ -118,7 +129,7 @@ void listDevices_correlatesAndDropsUnenrolled() { machine.setHostname("host-1"); when(hostMachineResolver.resolve(eq("t1"), anyList())).thenReturn(Map.of(1L, machine)); - PageResult result = service.listDevicesForSoftware("42", null, null, 0, 50); + PageResult result = service.listDevicesForSoftware("42", null, null, 0, 50, null); assertThat(result.items()).hasSize(1); SoftwareOnDeviceResponse row = result.items().get(0); @@ -272,7 +283,7 @@ void listDevices_oneRowPerMachine_versionsJoined() { pc.setHostname("pc"); when(hostMachineResolver.resolve(eq("t1"), anyList())).thenReturn(Map.of(1L, mac, 2L, mac, 3L, pc)); - PageResult result = service.listDevicesForSoftware("103", null, null, 0, 50); + PageResult result = service.listDevicesForSoftware("103", null, null, 0, 50, null); assertThat(result.items()) .extracting(row -> row.getDevice().getMachineId(), SoftwareOnDeviceResponse::getSoftwareVersion) @@ -303,12 +314,12 @@ void listDevices_statusFilter() { // The device is UP_TO_DATE or OUTDATED; a lifecycle status it cannot have filters it out. SoftwareOnDeviceFilterInput drop = new SoftwareOnDeviceFilterInput(); drop.setStatuses(List.of(SoftwareOnDeviceStatus.SCHEDULED_UNINSTALL)); - assertThat(service.listDevicesForSoftware("42", drop, null, 0, 50).items()).isEmpty(); + assertThat(service.listDevicesForSoftware("42", drop, null, 0, 50, null).items()).isEmpty(); // Selecting both possible static statuses keeps it. SoftwareOnDeviceFilterInput keep = new SoftwareOnDeviceFilterInput(); keep.setStatuses(List.of(SoftwareOnDeviceStatus.UP_TO_DATE, SoftwareOnDeviceStatus.OUTDATED)); - assertThat(service.listDevicesForSoftware("42", keep, null, 0, 50).items()).hasSize(1); + assertThat(service.listDevicesForSoftware("42", keep, null, 0, 50, null).items()).hasSize(1); } @Test @@ -333,7 +344,7 @@ void softwareDeviceFilters_facetsByStatus() { assertThat(filters.getStatuses()).isNotEmpty(); int total = filters.getStatuses().stream().mapToInt(SoftwareFilterOption::getCount).sum(); assertThat(total).isEqualTo(1); // one correlated device → one status bucket, count 1 - // non-null in the schema, not backed yet: empty, never null + // non-null in the schema: no device has a value for these, so empty, never null assertThat(filters.getInstalledBy()).isEmpty(); assertThat(filters.getAutoUpdateStatuses()).isEmpty(); assertThat(filters.getOrganizations()).isEmpty(); @@ -416,7 +427,7 @@ void listVulnerabilitiesForSoftware_dedupsByCve() { .thenReturn(new com.openframe.sdk.fleetmdm.model.Vulnerability()); PageResult result = - service.listVulnerabilitiesForSoftware("42", null, 0, 50, null, true); + service.listVulnerabilitiesForSoftware("42", null, null, 0, 50, null, true); assertThat(result.items()).hasSize(1); SoftwareVulnerabilityResponse row = result.items().get(0); @@ -436,7 +447,7 @@ void listVulnerabilitiesForSoftware_nullEnrichment_noNpe() { when(fleet.getVulnerability(org.mockito.ArgumentMatchers.anyString())).thenReturn(null); // 404 from Fleet PageResult result = - service.listVulnerabilitiesForSoftware("42", null, 0, 50, null, true); + service.listVulnerabilitiesForSoftware("42", null, null, 0, 50, null, true); assertThat(result.items()).hasSize(1); assertThat(result.items().get(0).getCveId()).isEqualTo("CVE-2026-00000"); @@ -695,6 +706,237 @@ private void simulateEnricherSetsCounts(Map countByName) { }).when(deviceCountEnricher).enrichFromHostSoftware(anyList(), any(), any(), any(), any()); } + @Test + @DisplayName("softwareDevices status: a device on the highest version seen is UP_TO_DATE, the rest OUTDATED, in numeric order") + void listDevices_statusAgainstTheHighestVersion() { + stubDevices("homebrew_packages", device("m-1", "9.0"), device("m-2", "10.0")); + + List rows = service.listDevicesForSoftware("42", null, null, 0, 50, null).items(); + + assertThat(rows).extracting(row -> row.getDevice().getMachineId(), SoftwareOnDeviceResponse::getStatus) + .containsExactlyInAnyOrder(tuple("m-1", SoftwareOnDeviceStatus.OUTDATED), tuple("m-2", SoftwareOnDeviceStatus.UP_TO_DATE)); + } + + @Test + @DisplayName("softwareDevices sort: by softwareVersion in numeric order, both directions; an unknown field is a client error before Fleet") + void listDevices_sortBySoftwareVersion() { + stubDevices("apps", device("m-1", "9.0"), device("m-2", "10.0"), device("m-3", "1.5")); + + assertThat(service.listDevicesForSoftware("42", null, null, 0, 50, sort("softwareVersion", SortDirection.ASC)).items()) + .extracting(SoftwareOnDeviceResponse::getSoftwareVersion).containsExactly("1.5", "9.0", "10.0"); + assertThat(service.listDevicesForSoftware("42", null, null, 0, 50, sort("softwareVersion", SortDirection.DESC)).items()) + .extracting(SoftwareOnDeviceResponse::getSoftwareVersion).containsExactly("10.0", "9.0", "1.5"); + } + + @Test + void listDevices_unknownSortField_rejectedBeforeFleet() { + assertThrows(BadRequestException.class, + () -> service.listDevicesForSoftware("42", null, null, 0, 50, sort("hostname", SortDirection.ASC))); + verifyNoInteractions(fleet); + } + + @Test + @DisplayName("softwareDevices installedBy: the package manager Fleet names; an app it only calls apps/programs is unknown, not Manually") + void listDevices_installedBy() { + stubDevices("homebrew_packages", device("m-1", "1.0")); + assertThat(service.listDevicesForSoftware("42", null, null, 0, 50, null).items()) + .extracting(SoftwareOnDeviceResponse::getInstalledBy).containsExactly(SoftwareSource.BREW); + + stubDevices("apps", device("m-1", "1.0")); + assertThat(service.listDevicesForSoftware("42", null, null, 0, 50, null).items().get(0).getInstalledBy()).isNull(); + } + + @Test + @DisplayName("softwareDevices filters: customer, device status, installedBy, tags and auto-update each narrow the list") + void listDevices_filters() { + Machine acme = device("m-1", "1.0"); + acme.setOrganizationId("org-acme"); + acme.setStatus(DeviceStatus.ONLINE); + Machine globex = device("m-2", "1.0"); + globex.setOrganizationId("org-globex"); + globex.setStatus(DeviceStatus.OFFLINE); + stubDevices("homebrew_packages", acme, globex); + autoUpdateOn("m-1"); + + SoftwareOnDeviceFilterInput byCustomer = new SoftwareOnDeviceFilterInput(); + byCustomer.setOrganizationIds(List.of("org-globex")); + assertThat(machineIds(byCustomer)).containsExactly("m-2"); + + SoftwareOnDeviceFilterInput byDeviceStatus = new SoftwareOnDeviceFilterInput(); + byDeviceStatus.setDeviceStatuses(List.of(DeviceStatus.ONLINE)); + assertThat(machineIds(byDeviceStatus)).containsExactly("m-1"); + + SoftwareOnDeviceFilterInput byAutoUpdate = new SoftwareOnDeviceFilterInput(); + byAutoUpdate.setAutoUpdateStatuses(List.of(SoftwareAutoUpdateStatus.ON)); + assertThat(machineIds(byAutoUpdate)).containsExactly("m-1"); + + SoftwareOnDeviceFilterInput byInstalledBy = new SoftwareOnDeviceFilterInput(); + byInstalledBy.setInstalledBy(List.of(SoftwareSource.WINGET)); + assertThat(machineIds(byInstalledBy)).isEmpty(); + + SoftwareOnDeviceFilterInput byTag = new SoftwareOnDeviceFilterInput(); + byTag.setDeviceTagIds(List.of("tag-1")); + when(tagAssignmentRepository.findByEntityIdInAndTagIdInAndEntityType(List.of("m-1", "m-2"), List.of("tag-1"), TagEntityType.DEVICE)) + .thenReturn(List.of(TagAssignment.builder().entityId("m-2").build())); + assertThat(machineIds(byTag)).containsExactly("m-2"); + } + + @Test + @DisplayName("softwareDevices: the auto-update filter sees every device, not only the page, and the count follows it") + void listDevices_autoUpdateFilter_beforeThePage() { + stubDevices("homebrew_packages", device("m-1", "1.0"), device("m-2", "1.0"), device("m-3", "1.0")); + autoUpdateOn("m-3"); + SoftwareOnDeviceFilterInput filter = new SoftwareOnDeviceFilterInput(); + filter.setAutoUpdateStatuses(List.of(SoftwareAutoUpdateStatus.ON)); + + PageResult page = service.listDevicesForSoftware("42", filter, null, 0, 1, null); + + assertThat(page.items()).extracting(row -> row.getDevice().getMachineId()).containsExactly("m-3"); + assertThat(page.filteredCount()).isEqualTo(1); + } + + @Test + @DisplayName("softwareDeviceFilters: installedBy, auto-update, device status and customer are counted over the devices") + void softwareDeviceFilters_allFacets() { + Machine acme = device("m-1", "1.0"); + acme.setOrganizationId("org-acme"); + acme.setStatus(DeviceStatus.ONLINE); + Machine alsoAcme = device("m-2", "1.0"); + alsoAcme.setOrganizationId("org-acme"); + alsoAcme.setStatus(DeviceStatus.OFFLINE); + Machine lost = device("m-3", "1.0"); + lost.setOrganizationId("org-gone"); + lost.setStatus(DeviceStatus.ONLINE); + stubDevices("homebrew_packages", acme, alsoAcme, lost); + autoUpdateOn("m-1"); + Organization organization = new Organization(); + organization.setOrganizationId("org-acme"); + organization.setName("Acme"); + when(organizationRepository.findByOrganizationIdIn(Set.of("org-acme", "org-gone"))).thenReturn(List.of(organization)); + + SoftwareOnDeviceFilters filters = service.getSoftwareDeviceFilters("42", null); + + assertThat(filters.getInstalledBy()).extracting(SoftwareFilterOption::getValue, SoftwareFilterOption::getCount) + .containsExactly(tuple("BREW", 3)); + assertThat(filters.getAutoUpdateStatuses()).extracting(SoftwareFilterOption::getValue, SoftwareFilterOption::getCount) + .containsExactly(tuple("ON", 1)); + assertThat(filters.getDeviceStatuses()).extracting(SoftwareFilterOption::getValue, SoftwareFilterOption::getCount) + .containsExactlyInAnyOrder(tuple("ONLINE", 2), tuple("OFFLINE", 1)); + // a customer without a record still shows, by its id + assertThat(filters.getOrganizations()).extracting(SoftwareFilterOption::getValue, SoftwareFilterOption::getLabel, SoftwareFilterOption::getCount) + .containsExactly(tuple("org-acme", "Acme", 2), tuple("org-gone", "org-gone", 1)); + } + + @Test + void softwareDeviceFilters_organizationWithoutName_labelledByItsId() { + Machine machine = device("m-1", "1.0"); + machine.setOrganizationId("org-unnamed"); + stubDevices("apps", machine); + Organization organization = new Organization(); + organization.setOrganizationId("org-unnamed"); + when(organizationRepository.findByOrganizationIdIn(Set.of("org-unnamed"))).thenReturn(List.of(organization)); + + assertThat(service.getSoftwareDeviceFilters("42", null).getOrganizations()) + .extracting(SoftwareFilterOption::getValue, SoftwareFilterOption::getLabel) + .containsExactly(tuple("org-unnamed", "org-unnamed")); + } + + @Test + void softwareDeviceFilters_noOrganizationOnAnyDevice_noLookup() { + stubDevices("apps", device("m-1", "1.0")); + + assertThat(service.getSoftwareDeviceFilters("42", null).getOrganizations()).isEmpty(); + verifyNoInteractions(organizationRepository); + } + + @Test + @DisplayName("softwareVulnerabilities: the severities filter keeps only those bands; no filter keeps all") + void listVulnerabilitiesForSoftware_severitiesFilter() { + SoftwareTitle title = new SoftwareTitle(); + title.setId(42L); + title.setVersions(List.of(versionWithCve("1.0", "CVE-HIGH"), versionWithCve("1.0", "CVE-LOW"))); + when(fleet.getSoftwareTitle(42L)).thenReturn(title); + when(fleet.getVulnerability("CVE-HIGH")).thenReturn(cvss(8.5)); + when(fleet.getVulnerability("CVE-LOW")).thenReturn(cvss(2.0)); + SoftwareVulnerabilityFilterInput high = new SoftwareVulnerabilityFilterInput(); + high.setSeverities(List.of(SoftwareCveSeverity.HIGH)); + + assertThat(service.listVulnerabilitiesForSoftware("42", high, null, 0, 50, null, true).items()) + .extracting(SoftwareVulnerabilityResponse::getCveId).containsExactly("CVE-HIGH"); + assertThat(service.listVulnerabilitiesForSoftware("42", null, null, 0, 50, null, true).items()).hasSize(2); + } + + @Test + @DisplayName("deviceSoftware: the auto-update filter narrows the rows and the count, and the facet counts it") + void listSoftwareForDevice_autoUpdateFilterAndFacet() { + stubInventory(List.of(title(10L, "Google Chrome", "apps", "120.0"), title(11L, "node", "homebrew_packages", "20.1")), List.of()); + org.mockito.Mockito.doAnswer(call -> { + List rows = call.getArgument(1); + rows.stream().filter(row -> "node".equals(row.getName())).forEach(row -> row.setAutoUpdate(SoftwareAutoUpdateStatus.ON)); + return null; + }).when(autoUpdateService).applyToSoftwareOnDevice(eq(MACHINE_ID), anyList()); + SoftwareFilterInput filter = new SoftwareFilterInput(); + filter.setAutoUpdateStatuses(List.of(SoftwareAutoUpdateStatus.ON)); + + PageResult page = service.listSoftwareForDevice(MACHINE_ID, filter, null, 0, 1, null); + + assertThat(page.items()).extracting(SoftwareResponse::getName).containsExactly("node"); + assertThat(page.filteredCount()).isEqualTo(1); + assertThat(service.getDeviceSoftwareFilters(MACHINE_ID, null).getAutoUpdateStatuses()) + .extracting(SoftwareFilterOption::getValue, SoftwareFilterOption::getCount).containsExactly(tuple("ON", 1)); + } + + private List machineIds(SoftwareOnDeviceFilterInput filter) { + return service.listDevicesForSoftware("42", filter, null, 0, 50, null).items().stream() + .map(row -> row.getDevice().getMachineId()).toList(); + } + + private void autoUpdateOn(String machineId) { + org.mockito.Mockito.doAnswer(call -> { + List rows = call.getArgument(1); + rows.stream().filter(row -> machineId.equals(row.getDevice().getMachineId())) + .forEach(row -> row.setAutoUpdate(SoftwareAutoUpdateStatus.ON)); + return null; + }).when(autoUpdateService).applyToDevices(eq("42"), anyList()); + } + + // one Fleet version per device; the version is carried on the machine's hostname for the stub below + private void stubDevices(String fleetSource, Machine... machines) { + SoftwareTitle title = new SoftwareTitle(); + title.setSource(fleetSource); + List versions = new java.util.ArrayList<>(); + Map hostByVersion = new java.util.HashMap<>(); + Map machineByHost = new java.util.HashMap<>(); + for (int i = 0; i < machines.length; i++) { + long id = i + 1L; + SoftwareTitleVersion version = new SoftwareTitleVersion(); + version.setId(id); + version.setVersion(machines[i].getHostname()); + versions.add(version); + hostByVersion.put(id, host(id, "u" + id, machines[i].getHostname())); + machineByHost.put(id, machines[i]); + } + title.setVersions(versions); + when(fleet.getSoftwareTitle(42L)).thenReturn(title); + when(fleet.searchHosts(any(HostSearchRequest.class))) + .thenAnswer(call -> List.of(hostByVersion.get(call.getArgument(0).getSoftwareVersionId()))); + when(tenantIdProvider.getTenantId()).thenReturn("t1"); + when(hostMachineResolver.resolve(eq("t1"), anyList())).thenReturn(machineByHost); + } + + private static Machine device(String machineId, String version) { + Machine machine = new Machine(); + machine.setMachineId(machineId); + machine.setHostname(version); + return machine; + } + + private static com.openframe.sdk.fleetmdm.model.Vulnerability cvss(double score) { + com.openframe.sdk.fleetmdm.model.Vulnerability vulnerability = new com.openframe.sdk.fleetmdm.model.Vulnerability(); + vulnerability.setCvssScore(score); + return vulnerability; + } + private void stubInventory(List titles, List hostSoftware) { when(deviceHostInventoryLoader.load(fleet, MACHINE_ID)).thenReturn(HostInventory.of(titles, hostSoftware)); when(fleet.listSoftwareTitles(any(SoftwareTitleRequest.class))).thenReturn(new SoftwareTitlesResponse()); diff --git a/openframe-api-lib/src/test/java/com/openframe/api/util/FleetSoftwareMapperTest.java b/openframe-api-lib/src/test/java/com/openframe/api/util/FleetSoftwareMapperTest.java new file mode 100644 index 000000000..67900cbd7 --- /dev/null +++ b/openframe-api-lib/src/test/java/com/openframe/api/util/FleetSoftwareMapperTest.java @@ -0,0 +1,37 @@ +package com.openframe.api.util; + +import com.openframe.api.dto.rmm.software.SoftwareResponse; +import com.openframe.sdk.fleetmdm.model.SoftwareTitle; +import com.openframe.sdk.fleetmdm.model.SoftwareTitleVersion; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class FleetSoftwareMapperTest { + + @Test + void toResponse_latestVersionIsTheHighest_inNumericOrder() { + SoftwareTitle title = new SoftwareTitle(); + title.setVersions(List.of(version("9.0"), version("10.0"), version(null))); + + SoftwareResponse row = FleetSoftwareMapper.toResponse(title); + + assertThat(row.getLatestVersion()).isEqualTo("10.0"); + } + + @Test + void toResponse_noVersions_noLatest() { + SoftwareTitle title = new SoftwareTitle(); + title.setName("x"); + + assertThat(FleetSoftwareMapper.toResponse(title).getLatestVersion()).isNull(); + } + + private static SoftwareTitleVersion version(String version) { + SoftwareTitleVersion v = new SoftwareTitleVersion(); + v.setVersion(version); + return v; + } +} diff --git a/openframe-api-lib/src/test/java/com/openframe/api/util/SoftwareVersionsTest.java b/openframe-api-lib/src/test/java/com/openframe/api/util/SoftwareVersionsTest.java new file mode 100644 index 000000000..122cbac75 --- /dev/null +++ b/openframe-api-lib/src/test/java/com/openframe/api/util/SoftwareVersionsTest.java @@ -0,0 +1,22 @@ +package com.openframe.api.util; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.assertj.core.api.Assertions.assertThat; + +class SoftwareVersionsTest { + + @ParameterizedTest + @CsvSource({"9.0,10.0", "1.2,1.2.1", "2.53.0,2.56.0_2", "1.2.2,1.2.3-beta", "1.0,20240101123456.0", "2.56.0_1,2.56.0_2"}) + void lowerComesFirst(String lower, String higher) { + assertThat(SoftwareVersions.compare(lower, higher)).isNegative(); + assertThat(SoftwareVersions.compare(higher, lower)).isPositive(); + } + + @Test + void sameVersion_isEqual() { + assertThat(SoftwareVersions.compare("1.2.3", "1.2.3")).isZero(); + } +} diff --git a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareDataFetcher.java b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareDataFetcher.java index b5c0c4d98..5e46dbf25 100644 --- a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareDataFetcher.java +++ b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareDataFetcher.java @@ -13,6 +13,7 @@ import com.openframe.api.dto.rmm.software.SoftwareOnDeviceFilters; import com.openframe.api.dto.rmm.software.SoftwareOnDeviceResponse; import com.openframe.api.dto.rmm.software.SoftwareResponse; +import com.openframe.api.dto.rmm.software.SoftwareVulnerabilityFilterInput; import com.openframe.api.dto.rmm.software.SoftwareVulnerabilityResponse; import com.openframe.api.dto.shared.SortDirection; import com.openframe.api.dto.shared.SortInput; @@ -71,16 +72,16 @@ public CountedGenericConnection> softwareD @InputArgument String softwareId, @InputArgument SoftwareOnDeviceFilterInput filter, @InputArgument Integer first, @InputArgument String after, @InputArgument Integer last, @InputArgument String before, - @InputArgument String search, @InputArgument Object sort) { + @InputArgument String search, @InputArgument SortInput sort) { int page = PageCursors.decodePage(after != null ? after : before); Integer perPage = first != null ? first : last; return PageCursors.toConnection( - softwareInventoryService.listDevicesForSoftware(softwareId, filter, search, page, perPage)); + softwareInventoryService.listDevicesForSoftware(softwareId, filter, search, page, perPage, sort)); } @DgsQuery public CountedGenericConnection> softwareVulnerabilities( - @InputArgument String softwareId, @InputArgument Object filter, + @InputArgument String softwareId, @InputArgument SoftwareVulnerabilityFilterInput filter, @InputArgument Integer first, @InputArgument String after, @InputArgument Integer last, @InputArgument String before, @InputArgument String search, @InputArgument SortInput sort) { @@ -89,7 +90,7 @@ public CountedGenericConnection> soft String sortField = sort == null ? null : sort.getField(); boolean asc = sort != null && sort.getDirection() == SortDirection.ASC; return PageCursors.toConnection(softwareInventoryService.listVulnerabilitiesForSoftware( - softwareId, search, page, perPage, sortField, asc)); + softwareId, filter, search, page, perPage, sortField, asc)); } @DgsQuery diff --git a/openframe-api-service-core/src/main/resources/schema/software.graphqls b/openframe-api-service-core/src/main/resources/schema/software.graphqls index 0715106eb..53eab4896 100644 --- a/openframe-api-service-core/src/main/resources/schema/software.graphqls +++ b/openframe-api-service-core/src/main/resources/schema/software.graphqls @@ -173,6 +173,9 @@ type Software { always 0 when autoUpdate is NO_PACKAGE (nothing to adopt with). Sortable as adoptionCount.""" adoptionCount: Int + + "Devices that have this title installed." + devicesCount: Int } type SoftwareVulnerabilitySummary {