diff --git a/.gitignore b/.gitignore index 3f5e3482..320a59f6 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,5 @@ test-plan-results/ # MCP local config .mcp.json test-plans-archive/ +.tldr/ +.tldrignore diff --git a/CLAUDE.md b/CLAUDE.md index 8557de74..5a15bf30 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -401,7 +401,9 @@ Human review is the bottleneck in AI-assisted development. Creating exceptional - Manual testing performed - Edge cases covered -**Goal**: Make reviewing effortless by providing all information the reviewer needs to understand and evaluate the changes with confidence and speed. The reviewer should not need to ask clarifying questions. +**Goal**: Make reviewing effortless by providing all information the reviewer needs to understand and evaluate the changes with confidence and speed. The reviewer should not need to ask clarifying questions. + +**IMPORTANT**: Ensure the text of the PR clearly explains all the changes and leads the developer through the logical progression of the chain of thought that produced it. ### Moving to Review diff --git a/src/main/java/com/contrast/labs/ai/mcp/contrast/data/RouteCoverageResponseLight.java b/src/main/java/com/contrast/labs/ai/mcp/contrast/data/RouteCoverageResponseLight.java new file mode 100644 index 00000000..6f36803f --- /dev/null +++ b/src/main/java/com/contrast/labs/ai/mcp/contrast/data/RouteCoverageResponseLight.java @@ -0,0 +1,43 @@ +/* + * Copyright 2025 Contrast Security + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.contrast.labs.ai.mcp.contrast.data; + +import java.util.List; + +/** + * Lightweight route coverage response containing essential route data and aggregate statistics. + * Uses RouteLight instead of full Route objects for reduced payload size. + * + * @param success Whether the API request succeeded + * @param messages Any API messages (warnings, info) + * @param totalRoutes Total number of routes in the response + * @param exercisedCount Number of routes with EXERCISED status + * @param discoveredCount Number of routes with DISCOVERED status (not yet exercised) + * @param coveragePercent Percentage of routes that are exercised (0.0 to 100.0) + * @param totalVulnerabilities Sum of vulnerabilities across all routes + * @param totalCriticalVulnerabilities Sum of critical vulnerabilities across all routes + * @param routes List of lightweight route objects + */ +public record RouteCoverageResponseLight( + boolean success, + List messages, + int totalRoutes, + int exercisedCount, + int discoveredCount, + double coveragePercent, + int totalVulnerabilities, + int totalCriticalVulnerabilities, + List routes) {} diff --git a/src/main/java/com/contrast/labs/ai/mcp/contrast/data/RouteLight.java b/src/main/java/com/contrast/labs/ai/mcp/contrast/data/RouteLight.java new file mode 100644 index 00000000..5e39de71 --- /dev/null +++ b/src/main/java/com/contrast/labs/ai/mcp/contrast/data/RouteLight.java @@ -0,0 +1,56 @@ +/* + * Copyright 2025 Contrast Security + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.contrast.labs.ai.mcp.contrast.data; + +import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.Observation; +import java.util.Collections; +import java.util.List; + +/** + * Lightweight route record for route coverage responses. Contains essential route information + * without redundant application data or full server details. + * + * @param signature Code-level route identifier (e.g., Java method signature) + * @param environments Distinct environments where route has been observed (DEVELOPMENT, QA, + * PRODUCTION) + * @param status Route status (DISCOVERED, EXERCISED) + * @param routeHash Unique route identifier hash + * @param vulnerabilities Total vulnerability count for this route + * @param criticalVulnerabilities Count of vulnerabilities with critical severity + * @param exercised Timestamp when route was last exercised (0 if never) + * @param discovered Timestamp when route was first discovered (immutable) + * @param serversTotal Count of distinct enabled servers where route has been observed + * @param observations List of observed HTTP interactions (verb + url) + * @param totalObservations Total observation count + */ +public record RouteLight( + String signature, + List environments, + String status, + String routeHash, + int vulnerabilities, + int criticalVulnerabilities, + long exercised, + long discovered, + int serversTotal, + List observations, + Long totalObservations) { + + /** Compact constructor ensures observations is never null. */ + public RouteLight { + observations = observations != null ? observations : Collections.emptyList(); + } +} diff --git a/src/main/java/com/contrast/labs/ai/mcp/contrast/mapper/RouteMapper.java b/src/main/java/com/contrast/labs/ai/mcp/contrast/mapper/RouteMapper.java new file mode 100644 index 00000000..7a18a202 --- /dev/null +++ b/src/main/java/com/contrast/labs/ai/mcp/contrast/mapper/RouteMapper.java @@ -0,0 +1,98 @@ +/* + * Copyright 2025 Contrast Security + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.contrast.labs.ai.mcp.contrast.mapper; + +import com.contrast.labs.ai.mcp.contrast.data.RouteCoverageResponseLight; +import com.contrast.labs.ai.mcp.contrast.data.RouteLight; +import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.Route; +import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.RouteCoverageResponse; +import java.util.Collections; +import java.util.Optional; +import org.springframework.stereotype.Component; + +/** + * Mapper for transforming Route objects into lightweight RouteLight representations. Eliminates + * redundant fields (app, servers, routeHashString) while preserving essential route coverage data. + */ +@Component +public class RouteMapper { + + /** + * Transform a Route object into a lightweight representation. Removes redundant app data, full + * server list, and legacy fields. + * + * @param route The full route object from SDK + * @return RouteLight with essential fields only + */ + public RouteLight toRouteLight(Route route) { + return new RouteLight( + route.getSignature(), + Optional.ofNullable(route.getEnvironments()).orElse(Collections.emptyList()), + route.getStatus(), + route.getRouteHash(), + route.getVulnerabilities(), + route.getCriticalVulnerabilities(), + route.getExercised(), + route.getDiscovered(), + route.getServersTotal(), + Optional.ofNullable(route.getObservations()).orElse(Collections.emptyList()), + route.getTotalObservations()); + } + + /** + * Transform full RouteCoverageResponse to lightweight version with aggregate statistics. Uses + * single-pass computation for efficiency with large route lists. + * + * @param response Full route coverage response from SDK + * @return Lightweight response with RouteLight objects and computed aggregates + */ + public RouteCoverageResponseLight toResponseLight(RouteCoverageResponse response) { + var routes = Optional.ofNullable(response.getRoutes()).orElse(Collections.emptyList()); + + var lightRoutes = routes.stream().map(this::toRouteLight).toList(); + + // Single-pass computation for all aggregates + int exercisedCount = 0; + int discoveredCount = 0; + int totalVulnerabilities = 0; + int totalCriticalVulnerabilities = 0; + + for (var route : routes) { + if ("EXERCISED".equalsIgnoreCase(route.getStatus())) { + exercisedCount++; + } else if ("DISCOVERED".equalsIgnoreCase(route.getStatus())) { + discoveredCount++; + } + totalVulnerabilities += route.getVulnerabilities(); + totalCriticalVulnerabilities += route.getCriticalVulnerabilities(); + } + + int totalRoutes = routes.size(); + double coveragePercent = + totalRoutes > 0 ? Math.round((exercisedCount * 100.0) / totalRoutes * 100.0) / 100.0 : 0.0; + + return new RouteCoverageResponseLight( + response.isSuccess(), + response.getMessages(), + totalRoutes, + exercisedCount, + discoveredCount, + coveragePercent, + totalVulnerabilities, + totalCriticalVulnerabilities, + lightRoutes); + } +} diff --git a/src/main/java/com/contrast/labs/ai/mcp/contrast/sdkextension/SDKExtension.java b/src/main/java/com/contrast/labs/ai/mcp/contrast/sdkextension/SDKExtension.java index d38d961f..3cc074a4 100644 --- a/src/main/java/com/contrast/labs/ai/mcp/contrast/sdkextension/SDKExtension.java +++ b/src/main/java/com/contrast/labs/ai/mcp/contrast/sdkextension/SDKExtension.java @@ -22,7 +22,6 @@ import com.contrast.labs.ai.mcp.contrast.sdkextension.data.adr.AttacksResponse; import com.contrast.labs.ai.mcp.contrast.sdkextension.data.application.ApplicationsResponse; import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.RouteCoverageResponse; -import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.RouteDetailsResponse; import com.contrast.labs.ai.mcp.contrast.sdkextension.data.sca.LibraryObservation; import com.contrast.labs.ai.mcp.contrast.sdkextension.data.sca.LibraryObservationsResponse; import com.contrast.labs.ai.mcp.contrast.sdkextension.data.sessionmetadata.SessionMetadataResponse; @@ -175,34 +174,16 @@ private String getLibraryObservationsUrl( organizationId, applicationId, libraryId, offset, limit); } - /** - * Retrieves the detailed observations for a specific route. - * - * @param organizationId The organization ID - * @param applicationId The application ID - * @param routeHash The unique hash identifying the route - * @return RouteDetailsResponse containing observations for the route - * @throws IOException If an I/O error occurs - * @throws UnauthorizedException If the request is not authorized - */ - public RouteDetailsResponse getRouteDetails( - String organizationId, String applicationId, String routeHash) - throws IOException, UnauthorizedException { - var url = getRouteDetailsUrl(organizationId, applicationId, routeHash); - - try (InputStream is = contrastSDK.makeRequest(HttpMethod.GET, url); - Reader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) { - return gson.fromJson(reader, RouteDetailsResponse.class); - } - } - /** * Retrieves route coverage information for an application. * + *

Uses GET with expand=observations for unfiltered requests, POST for filtered requests. Both + * include observations inline, eliminating N+1 queries for route details. + * * @param organizationId The organization ID * @param appId The application ID - * @param metadata Optional metadata request for filtering (can be null) - * @return RouteCoverageResponse containing route coverage information + * @param metadata Optional metadata request for filtering (can be null for unfiltered) + * @return RouteCoverageResponse containing route coverage information with observations * @throws IOException If an I/O error occurs * @throws UnauthorizedException If the request is not authorized */ @@ -210,37 +191,24 @@ public RouteCoverageResponse getRouteCoverage( String organizationId, String appId, RouteCoverageBySessionIDAndMetadataRequest metadata) throws IOException, UnauthorizedException { - InputStream is = null; - - try { - if (metadata == null) { - is = - contrastSDK.makeRequest( - HttpMethod.GET, urlBuilder.getRouteCoverageUrl(organizationId, appId)); - } else { - is = - contrastSDK.makeRequestWithBody( - HttpMethod.POST, - urlBuilder.getRouteCoverageWithMetadataUrl(organizationId, appId), - gson.toJson(metadata), - MediaType.JSON); - } + InputStream is; - try (Reader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) { - return gson.fromJson(reader, RouteCoverageResponse.class); - } - } finally { - if (is != null) { - is.close(); - } + if (metadata == null) { + // GET for unfiltered - add expand=observations to include observations inline + var url = urlBuilder.getRouteCoverageUrl(organizationId, appId) + "&expand=observations"; + is = contrastSDK.makeRequest(HttpMethod.GET, url); + } else { + // POST for filtered - URL already includes expand=observations + var url = urlBuilder.getRouteCoverageWithMetadataUrl(organizationId, appId); + is = + contrastSDK.makeRequestWithBody( + HttpMethod.POST, url, gson.toJson(metadata), MediaType.JSON); } - } - /** Builds URL for retrieving route details observations */ - private String getRouteDetailsUrl(String organizationId, String applicationId, String routeHash) { - return String.format( - "/ng/%s/applications/%s/route/%s/observations?expand=skip_links,session_metadata", - organizationId, applicationId, routeHash); + try (is; + Reader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) { + return gson.fromJson(reader, RouteCoverageResponse.class); + } } /** diff --git a/src/main/java/com/contrast/labs/ai/mcp/contrast/sdkextension/data/routecoverage/Route.java b/src/main/java/com/contrast/labs/ai/mcp/contrast/sdkextension/data/routecoverage/Route.java index 8165c757..b8d14db8 100644 --- a/src/main/java/com/contrast/labs/ai/mcp/contrast/sdkextension/data/routecoverage/Route.java +++ b/src/main/java/com/contrast/labs/ai/mcp/contrast/sdkextension/data/routecoverage/Route.java @@ -32,8 +32,6 @@ public class Route { private long discovered; private String status; - private RouteDetailsResponse routeDetailsResponse; - @SerializedName("route_hash") private String routeHash; @@ -45,4 +43,10 @@ public class Route { @SerializedName("critical_vulnerabilities") private int criticalVulnerabilities; + + @SerializedName("observations") + private List observations; + + @SerializedName("total_observations") + private Long totalObservations; } diff --git a/src/main/java/com/contrast/labs/ai/mcp/contrast/sdkextension/data/routecoverage/RouteDetailsResponse.java b/src/main/java/com/contrast/labs/ai/mcp/contrast/sdkextension/data/routecoverage/RouteDetailsResponse.java deleted file mode 100644 index 0d9ec976..00000000 --- a/src/main/java/com/contrast/labs/ai/mcp/contrast/sdkextension/data/routecoverage/RouteDetailsResponse.java +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright 2025 Contrast Security - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage; - -import java.util.List; -import lombok.Data; - -/** Represents the response for route details containing observations. */ -@Data -public class RouteDetailsResponse { - - private boolean success; - private List messages; - private List observations; - private int total; -} diff --git a/src/main/java/com/contrast/labs/ai/mcp/contrast/tool/coverage/GetRouteCoverageTool.java b/src/main/java/com/contrast/labs/ai/mcp/contrast/tool/coverage/GetRouteCoverageTool.java index c254bf77..fa97fd10 100644 --- a/src/main/java/com/contrast/labs/ai/mcp/contrast/tool/coverage/GetRouteCoverageTool.java +++ b/src/main/java/com/contrast/labs/ai/mcp/contrast/tool/coverage/GetRouteCoverageTool.java @@ -15,10 +15,10 @@ */ package com.contrast.labs.ai.mcp.contrast.tool.coverage; +import com.contrast.labs.ai.mcp.contrast.data.RouteCoverageResponseLight; +import com.contrast.labs.ai.mcp.contrast.mapper.RouteMapper; import com.contrast.labs.ai.mcp.contrast.sdkextension.SDKExtension; -import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.Route; import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.RouteCoverageBySessionIDAndMetadataRequestExtended; -import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.RouteCoverageResponse; import com.contrast.labs.ai.mcp.contrast.tool.base.BaseSingleTool; import com.contrast.labs.ai.mcp.contrast.tool.base.SingleToolResponse; import com.contrast.labs.ai.mcp.contrast.tool.coverage.params.RouteCoverageParams; @@ -39,7 +39,9 @@ @RequiredArgsConstructor @Slf4j public class GetRouteCoverageTool - extends BaseSingleTool { + extends BaseSingleTool { + + private final RouteMapper routeMapper; @Tool( name = "get_route_coverage", @@ -72,7 +74,7 @@ Filtering options (mutually exclusive): - search_applications: Find application IDs by name or tag - get_session_metadata: View available session metadata fields """) - public SingleToolResponse getRouteCoverage( + public SingleToolResponse getRouteCoverage( @ToolParam(description = "Application ID (use search_applications to find)") String appId, @ToolParam( description = @@ -99,7 +101,7 @@ public SingleToolResponse getRouteCoverage( } @Override - protected RouteCoverageResponse doExecute(RouteCoverageParams params, List warnings) + protected RouteCoverageResponseLight doExecute(RouteCoverageParams params, List warnings) throws Exception { var sdk = getContrastSDK(); var orgId = getOrgId(); @@ -159,20 +161,15 @@ protected RouteCoverageResponse doExecute(RouteCoverageParams params, List parseValues(Object val, String fieldName, List inva } else if (val instanceof Number n) { return List.of(formatNumber(n)); } else if (val instanceof List list) { + if (list.isEmpty()) { + invalidEntries.add( + String.format("'%s' (empty array - must have at least one value)", fieldName)); + return null; + } var strings = new ArrayList(); for (Object item : list) { if (item instanceof String s) { @@ -105,7 +110,7 @@ private List parseValues(Object val, String fieldName, List inva return null; } } - return strings.isEmpty() ? null : List.copyOf(strings); + return List.copyOf(strings); } else if (val != null) { invalidEntries.add(String.format("'%s' (expected string or array of strings)", fieldName)); } diff --git a/src/main/java/com/contrast/labs/ai/mcp/contrast/utils/PaginationHandler.java b/src/main/java/com/contrast/labs/ai/mcp/contrast/utils/PaginationHandler.java deleted file mode 100644 index 8ad8db6d..00000000 --- a/src/main/java/com/contrast/labs/ai/mcp/contrast/utils/PaginationHandler.java +++ /dev/null @@ -1,126 +0,0 @@ -/* - * Copyright 2025 Contrast Security - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.contrast.labs.ai.mcp.contrast.utils; - -import com.contrast.labs.ai.mcp.contrast.PaginationParams; -import com.contrast.labs.ai.mcp.contrast.tool.base.PaginatedToolResponse; -import java.util.ArrayList; -import java.util.List; -import org.springframework.stereotype.Component; -import org.springframework.util.CollectionUtils; -import org.springframework.util.StringUtils; - -/** - * Centralized pagination handler for all MCP Server endpoints. Provides consistent pagination logic - * including: - hasMorePages calculation (based on totalCount or heuristic) - Empty result messaging - * - Response construction with validation messages - */ -@Component -public class PaginationHandler { - - /** - * Creates a PaginatedResponse from API-paginated items. Use this when the API has already - * paginated the data (e.g., SDK returns page of results). - * - * @param items The items returned by the API for this page - * @param params Validated pagination parameters - * @param totalCount Total count from API (null if unavailable) - * @param Type of items - * @return PaginatedResponse with calculated hasMorePages and messages - */ - public PaginatedToolResponse createPaginatedResponse( - List items, PaginationParams params, Integer totalCount) { - return createPaginatedResponse(items, params, totalCount, List.of()); - } - - /** - * Creates a PaginatedResponse from API-paginated items with additional warnings. Use this when - * the API has already paginated the data (e.g., SDK returns page of results). - * - * @param items The items returned by the API for this page - * @param params Validated pagination parameters - * @param totalCount Total count from API (null if unavailable) - * @param additionalWarnings Extra warnings to include (e.g., filter validation warnings) - * @param Type of items - * @return PaginatedResponse with calculated hasMorePages and messages - */ - public PaginatedToolResponse createPaginatedResponse( - List items, PaginationParams params, Integer totalCount, List additionalWarnings) { - boolean hasMorePages = calculateHasMorePages(params, totalCount, items.size()); - var emptyResultWarning = buildEmptyResultMessage(items, params, totalCount); - - // Collect all warnings - var warnings = new ArrayList(); - warnings.addAll(params.warnings()); - if (!CollectionUtils.isEmpty(additionalWarnings)) { - warnings.addAll(additionalWarnings); - } - if (StringUtils.hasText(emptyResultWarning)) { - warnings.add(emptyResultWarning); - } - - return PaginatedToolResponse.success( - items, params.page(), params.pageSize(), totalCount, hasMorePages, warnings, null); - } - - /** - * Calculate whether more pages exist. Uses totalCount if available, otherwise uses heuristic - * (full page = more exist). - * - * @param params Pagination parameters - * @param totalCount Total count from API (null if unavailable) - * @param itemsReturned Number of items in current page - * @return true if more pages likely exist - */ - private boolean calculateHasMorePages( - PaginationParams params, Integer totalCount, int itemsReturned) { - if (totalCount != null) { - // Accurate calculation: more pages exist if we haven't fetched everything - return (params.page() * params.pageSize()) < totalCount; - } else { - // Heuristic: if we got a full page, assume more exist - return itemsReturned == params.pageSize(); - } - } - - /** - * Build helpful message for empty results or page-beyond-bounds scenarios. - * - * @param items Items in current page - * @param params Pagination parameters - * @param totalCount Total count (null if unavailable) - * @return Message string or null if no message needed - */ - private String buildEmptyResultMessage( - List items, PaginationParams params, Integer totalCount) { - if (!items.isEmpty()) { - return null; // No message needed for non-empty results - } - - if (params.page() == 1) { - return "No items found."; - } else { - // Page > 1 with empty results - if (totalCount != null) { - int totalPages = (int) Math.ceil((double) totalCount / params.pageSize()); - return String.format( - "Requested page %d exceeds available pages (total: %d).", params.page(), totalPages); - } else { - return String.format("Requested page %d returned no results.", params.page()); - } - } - } -} diff --git a/src/test/java/com/contrast/labs/ai/mcp/contrast/data/RouteCoverageResponseLightTest.java b/src/test/java/com/contrast/labs/ai/mcp/contrast/data/RouteCoverageResponseLightTest.java new file mode 100644 index 00000000..fd1f2e9a --- /dev/null +++ b/src/test/java/com/contrast/labs/ai/mcp/contrast/data/RouteCoverageResponseLightTest.java @@ -0,0 +1,68 @@ +/* + * Copyright 2025 Contrast Security + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.contrast.labs.ai.mcp.contrast.data; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class RouteCoverageResponseLightTest { + + @Test + void should_create_response_with_routes_and_aggregates() { + var route = + new RouteLight( + "signature", + List.of("PRODUCTION"), + "EXERCISED", + "hash", + 1, + 0, + 100L, + 50L, + 2, + List.of(), + 0L); + + var response = + new RouteCoverageResponseLight(true, List.of(), 10, 7, 3, 70.0, 15, 5, List.of(route)); + + assertThat(response.success()).isTrue(); + assertThat(response.messages()).isEmpty(); + assertThat(response.totalRoutes()).isEqualTo(10); + assertThat(response.exercisedCount()).isEqualTo(7); + assertThat(response.discoveredCount()).isEqualTo(3); + assertThat(response.coveragePercent()).isCloseTo(70.0, within(0.01)); + assertThat(response.totalVulnerabilities()).isEqualTo(15); + assertThat(response.totalCriticalVulnerabilities()).isEqualTo(5); + assertThat(response.routes()).hasSize(1); + } + + @Test + void should_handle_empty_routes_with_zero_aggregates() { + var response = + new RouteCoverageResponseLight( + true, List.of("No routes found"), 0, 0, 0, 0.0, 0, 0, List.of()); + + assertThat(response.success()).isTrue(); + assertThat(response.messages()).containsExactly("No routes found"); + assertThat(response.totalRoutes()).isZero(); + assertThat(response.coveragePercent()).isZero(); + assertThat(response.routes()).isEmpty(); + } +} diff --git a/src/test/java/com/contrast/labs/ai/mcp/contrast/data/RouteLightTest.java b/src/test/java/com/contrast/labs/ai/mcp/contrast/data/RouteLightTest.java new file mode 100644 index 00000000..62bf0ed5 --- /dev/null +++ b/src/test/java/com/contrast/labs/ai/mcp/contrast/data/RouteLightTest.java @@ -0,0 +1,67 @@ +/* + * Copyright 2025 Contrast Security + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.contrast.labs.ai.mcp.contrast.data; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.Observation; +import java.util.List; +import org.junit.jupiter.api.Test; + +class RouteLightTest { + + @Test + void should_create_route_light_with_all_fields() { + var observation = new Observation(); + observation.setVerb("GET"); + observation.setUrl("/api/users"); + + var route = + new RouteLight( + "org.example.Controller.getUsers()", + List.of("PRODUCTION", "QA"), + "EXERCISED", + "hash-123", + 5, + 2, + 1704067200000L, + 1704063600000L, + 3, + List.of(observation), + 1L); + + assertThat(route.signature()).isEqualTo("org.example.Controller.getUsers()"); + assertThat(route.environments()).containsExactly("PRODUCTION", "QA"); + assertThat(route.status()).isEqualTo("EXERCISED"); + assertThat(route.routeHash()).isEqualTo("hash-123"); + assertThat(route.vulnerabilities()).isEqualTo(5); + assertThat(route.criticalVulnerabilities()).isEqualTo(2); + assertThat(route.exercised()).isEqualTo(1704067200000L); + assertThat(route.discovered()).isEqualTo(1704063600000L); + assertThat(route.serversTotal()).isEqualTo(3); + assertThat(route.observations()).hasSize(1); + assertThat(route.totalObservations()).isEqualTo(1L); + } + + @Test + void should_convert_null_observations_to_empty_list() { + var route = + new RouteLight("signature", List.of(), "DISCOVERED", "hash", 0, 0, 0L, 0L, 0, null, null); + + assertThat(route.observations()).isEmpty(); + assertThat(route.totalObservations()).isNull(); + } +} diff --git a/src/test/java/com/contrast/labs/ai/mcp/contrast/mapper/RouteMapperTest.java b/src/test/java/com/contrast/labs/ai/mcp/contrast/mapper/RouteMapperTest.java new file mode 100644 index 00000000..b9bf9f18 --- /dev/null +++ b/src/test/java/com/contrast/labs/ai/mcp/contrast/mapper/RouteMapperTest.java @@ -0,0 +1,220 @@ +/* + * Copyright 2025 Contrast Security + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.contrast.labs.ai.mcp.contrast.mapper; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.App; +import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.Observation; +import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.Route; +import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.RouteCoverageResponse; +import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.Server; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class RouteMapperTest { + + private RouteMapper mapper; + + @BeforeEach + void setUp() { + mapper = new RouteMapper(); + } + + @Test + void toRouteLight_should_map_all_kept_fields() { + var observation = new Observation(); + observation.setVerb("GET"); + observation.setUrl("/api/users"); + + var route = new Route(); + route.setSignature("org.example.Controller.getUsers()"); + route.setEnvironments(List.of("PRODUCTION")); + route.setStatus("EXERCISED"); + route.setRouteHash("hash-123"); + route.setVulnerabilities(5); + route.setCriticalVulnerabilities(2); + route.setExercised(1704067200000L); + route.setDiscovered(1704063600000L); + route.setServersTotal(3); + route.setObservations(List.of(observation)); + route.setTotalObservations(1L); + + var result = mapper.toRouteLight(route); + + assertThat(result.signature()).isEqualTo("org.example.Controller.getUsers()"); + assertThat(result.environments()).containsExactly("PRODUCTION"); + assertThat(result.status()).isEqualTo("EXERCISED"); + assertThat(result.routeHash()).isEqualTo("hash-123"); + assertThat(result.vulnerabilities()).isEqualTo(5); + assertThat(result.criticalVulnerabilities()).isEqualTo(2); + assertThat(result.exercised()).isEqualTo(1704067200000L); + assertThat(result.discovered()).isEqualTo(1704063600000L); + assertThat(result.serversTotal()).isEqualTo(3); + assertThat(result.observations()).hasSize(1); + assertThat(result.observations().get(0).getVerb()).isEqualTo("GET"); + assertThat(result.totalObservations()).isEqualTo(1L); + } + + @Test + void toRouteLight_should_exclude_removed_fields() { + var app = new App(); + app.setName("TestApp"); + app.setAppId("app-123"); + + var server = new Server(); + server.setName("server-1"); + + var route = new Route(); + route.setSignature("signature"); + route.setApp(app); + route.setServers(List.of(server)); + route.setRouteHashString("hash-string-should-be-excluded"); + + var result = mapper.toRouteLight(route); + + // RouteLight record doesn't have app, servers, or routeHashString fields + // If it compiled, those fields were successfully excluded + assertThat(result.signature()).isEqualTo("signature"); + } + + @Test + void toRouteLight_should_handle_null_observations() { + var route = new Route(); + route.setSignature("signature"); + route.setObservations(null); + + var result = mapper.toRouteLight(route); + + assertThat(result.observations()).isEmpty(); + } + + @Test + void toRouteLight_should_handle_null_environments() { + var route = new Route(); + route.setSignature("signature"); + route.setEnvironments(null); + + var result = mapper.toRouteLight(route); + + assertThat(result.environments()).isEmpty(); + } + + @Test + void toResponseLight_should_compute_aggregates() { + var exercisedRoute = new Route(); + exercisedRoute.setSignature("sig1"); + exercisedRoute.setStatus("EXERCISED"); + exercisedRoute.setVulnerabilities(3); + exercisedRoute.setCriticalVulnerabilities(1); + + var discoveredRoute = new Route(); + discoveredRoute.setSignature("sig2"); + discoveredRoute.setStatus("DISCOVERED"); + discoveredRoute.setVulnerabilities(2); + discoveredRoute.setCriticalVulnerabilities(0); + + var response = new RouteCoverageResponse(); + response.setSuccess(true); + response.setMessages(List.of("OK")); + response.setRoutes(List.of(exercisedRoute, discoveredRoute)); + + var result = mapper.toResponseLight(response); + + assertThat(result.success()).isTrue(); + assertThat(result.messages()).containsExactly("OK"); + assertThat(result.totalRoutes()).isEqualTo(2); + assertThat(result.exercisedCount()).isEqualTo(1); + assertThat(result.discoveredCount()).isEqualTo(1); + assertThat(result.coveragePercent()).isCloseTo(50.0, within(0.01)); + assertThat(result.totalVulnerabilities()).isEqualTo(5); + assertThat(result.totalCriticalVulnerabilities()).isEqualTo(1); + assertThat(result.routes()).hasSize(2); + } + + @Test + void toResponseLight_should_handle_null_routes() { + var response = new RouteCoverageResponse(); + response.setSuccess(true); + response.setRoutes(null); + + var result = mapper.toResponseLight(response); + + assertThat(result.totalRoutes()).isZero(); + assertThat(result.exercisedCount()).isZero(); + assertThat(result.discoveredCount()).isZero(); + assertThat(result.coveragePercent()).isZero(); + assertThat(result.routes()).isEmpty(); + } + + @Test + void toResponseLight_should_handle_all_exercised() { + var route1 = new Route(); + route1.setStatus("EXERCISED"); + var route2 = new Route(); + route2.setStatus("EXERCISED"); + + var response = new RouteCoverageResponse(); + response.setSuccess(true); + response.setRoutes(List.of(route1, route2)); + + var result = mapper.toResponseLight(response); + + assertThat(result.totalRoutes()).isEqualTo(2); + assertThat(result.exercisedCount()).isEqualTo(2); + assertThat(result.discoveredCount()).isZero(); + assertThat(result.coveragePercent()).isCloseTo(100.0, within(0.01)); + } + + @Test + void toResponseLight_should_match_status_case_insensitively() { + var route1 = new Route(); + route1.setStatus("exercised"); // lowercase + var route2 = new Route(); + route2.setStatus("Discovered"); // mixed case + + var response = new RouteCoverageResponse(); + response.setSuccess(true); + response.setRoutes(List.of(route1, route2)); + + var result = mapper.toResponseLight(response); + + assertThat(result.exercisedCount()).isEqualTo(1); + assertThat(result.discoveredCount()).isEqualTo(1); + } + + @Test + void toResponseLight_should_round_coverage_percent_to_two_decimals() { + // 1 out of 3 exercised = 33.333...% + var exercised = new Route(); + exercised.setStatus("EXERCISED"); + var discovered1 = new Route(); + discovered1.setStatus("DISCOVERED"); + var discovered2 = new Route(); + discovered2.setStatus("DISCOVERED"); + + var response = new RouteCoverageResponse(); + response.setSuccess(true); + response.setRoutes(List.of(exercised, discovered1, discovered2)); + + var result = mapper.toResponseLight(response); + + // Should be rounded to 33.33, not 33.333... + assertThat(result.coveragePercent()).isEqualTo(33.33); + } +} diff --git a/src/test/java/com/contrast/labs/ai/mcp/contrast/sdkextension/SDKExtensionTest.java b/src/test/java/com/contrast/labs/ai/mcp/contrast/sdkextension/SDKExtensionTest.java new file mode 100644 index 00000000..d6020de8 --- /dev/null +++ b/src/test/java/com/contrast/labs/ai/mcp/contrast/sdkextension/SDKExtensionTest.java @@ -0,0 +1,92 @@ +/* + * Copyright 2025 Contrast Security + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.contrast.labs.ai.mcp.contrast.sdkextension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.RouteCoverageBySessionIDAndMetadataRequestExtended; +import com.contrastsecurity.http.HttpMethod; +import com.contrastsecurity.http.MediaType; +import com.contrastsecurity.sdk.ContrastSDK; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class SDKExtensionTest { + + private ContrastSDK sdk; + private SDKExtension sdkExtension; + + @BeforeEach + void setUp() { + sdk = mock(); + sdkExtension = new SDKExtension(sdk); + } + + @Test + void getRouteCoverage_should_use_get_with_expand_observations_when_no_metadata() + throws Exception { + var emptyResponse = + """ + {"success": true, "routes": []} + """; + when(sdk.makeRequest(any(), any())) + .thenReturn(new ByteArrayInputStream(emptyResponse.getBytes(StandardCharsets.UTF_8))); + + var result = sdkExtension.getRouteCoverage("org-123", "app-456", null); + + assertThat(result.isSuccess()).isTrue(); + + // Verify GET is used with expand=observations in URL + verify(sdk) + .makeRequest( + eq(HttpMethod.GET), + argThat(url -> url.contains("/route?") && url.contains("expand=observations"))); + } + + @Test + void getRouteCoverage_should_use_post_with_metadata_body_when_metadata_provided() + throws Exception { + var emptyResponse = + """ + {"success": true, "routes": []} + """; + when(sdk.makeRequestWithBody(any(), any(), any(), any())) + .thenReturn(new ByteArrayInputStream(emptyResponse.getBytes(StandardCharsets.UTF_8))); + + var metadata = new RouteCoverageBySessionIDAndMetadataRequestExtended(); + metadata.setSessionId("session-123"); + + var result = sdkExtension.getRouteCoverage("org-123", "app-456", metadata); + + assertThat(result.isSuccess()).isTrue(); + + // Verify POST is used with metadata in body + verify(sdk) + .makeRequestWithBody( + eq(HttpMethod.POST), + argThat(url -> url.contains("/route/filter") && url.contains("expand=observations")), + argThat(body -> body.contains("session-123")), + eq(MediaType.JSON)); + } +} diff --git a/src/test/java/com/contrast/labs/ai/mcp/contrast/sdkextension/data/routecoverage/RouteTest.java b/src/test/java/com/contrast/labs/ai/mcp/contrast/sdkextension/data/routecoverage/RouteTest.java new file mode 100644 index 00000000..5e900c2f --- /dev/null +++ b/src/test/java/com/contrast/labs/ai/mcp/contrast/sdkextension/data/routecoverage/RouteTest.java @@ -0,0 +1,66 @@ +/* + * Copyright 2025 Contrast Security + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.gson.Gson; +import org.junit.jupiter.api.Test; + +class RouteTest { + + private final Gson gson = new Gson(); + + @Test + void should_deserialize_observations_from_json() { + var json = + """ + { + "route_hash": "abc123", + "signature": "GET /api/users", + "observations": [ + {"verb": "GET", "url": "/api/users"}, + {"verb": "POST", "url": "/api/users"} + ], + "total_observations": 2 + } + """; + + var route = gson.fromJson(json, Route.class); + + assertThat(route.getRouteHash()).isEqualTo("abc123"); + assertThat(route.getObservations()).hasSize(2); + assertThat(route.getObservations().get(0).getVerb()).isEqualTo("GET"); + assertThat(route.getObservations().get(1).getVerb()).isEqualTo("POST"); + assertThat(route.getTotalObservations()).isEqualTo(2L); + } + + @Test + void should_handle_null_observations() { + var json = + """ + { + "route_hash": "abc123", + "signature": "GET /api/users" + } + """; + + var route = gson.fromJson(json, Route.class); + + assertThat(route.getObservations()).isNull(); + assertThat(route.getTotalObservations()).isNull(); + } +} diff --git a/src/test/java/com/contrast/labs/ai/mcp/contrast/tool/coverage/GetRouteCoverageToolIT.java b/src/test/java/com/contrast/labs/ai/mcp/contrast/tool/coverage/GetRouteCoverageToolIT.java index e6db56a3..79edfe0d 100644 --- a/src/test/java/com/contrast/labs/ai/mcp/contrast/tool/coverage/GetRouteCoverageToolIT.java +++ b/src/test/java/com/contrast/labs/ai/mcp/contrast/tool/coverage/GetRouteCoverageToolIT.java @@ -18,7 +18,7 @@ import static org.assertj.core.api.Assertions.assertThat; import com.contrast.labs.ai.mcp.contrast.config.IntegrationTestConfig; -import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.Route; +import com.contrast.labs.ai.mcp.contrast.data.RouteLight; import com.contrast.labs.ai.mcp.contrast.util.AbstractIntegrationTest; import com.contrast.labs.ai.mcp.contrast.util.TestDataDiscoveryHelper; import com.contrast.labs.ai.mcp.contrast.util.TestDataDiscoveryHelper.RouteCoverageTestData; @@ -204,29 +204,23 @@ void getRouteCoverage_should_retrieve_routes_unfiltered() { assertThat(result.isSuccess()).as("Response should indicate success").isTrue(); assertThat(result.found()).as("Should find routes").isTrue(); assertThat(result.data()).as("Data should not be null").isNotNull(); - assertThat(result.data().getRoutes()).as("Routes should not be null").isNotNull(); - assertThat(result.data().getRoutes().size() > 0).as("Should have at least 1 route").isTrue(); + assertThat(result.data().routes()).as("Routes should not be null").isNotNull(); + assertThat(result.data().routes().size() > 0).as("Should have at least 1 route").isTrue(); log.info( "✓ Retrieved {} routes for application: {}", - result.data().getRoutes().size(), + result.data().routes().size(), testData.appName); - // Count exercised vs discovered routes - long exercisedCount = - result.data().getRoutes().stream().filter(route -> route.getExercised() > 0).count(); - long discoveredCount = result.data().getRoutes().size() - exercisedCount; + // Light response includes aggregate statistics + log.info(" Exercised routes: {}", result.data().exercisedCount()); + log.info(" Discovered routes: {}", result.data().discoveredCount()); + log.info(" Coverage percent: {}%", result.data().coveragePercent()); - log.info(" Exercised routes: {}", exercisedCount); - log.info(" Discovered routes: {}", discoveredCount); - - // Verify all routes have details - for (Route route : result.data().getRoutes()) { - assertThat(route.getSignature()).as("Route signature should not be null").isNotNull(); - assertThat(route.getRouteHash()).as("Route hash should not be null").isNotNull(); - assertThat(route.getRouteDetailsResponse()) - .as("Route details should be populated") - .isNotNull(); + // Verify all routes have essential fields in light format + for (RouteLight route : result.data().routes()) { + assertThat(route.signature()).as("Route signature should not be null").isNotNull(); + assertThat(route.routeHash()).as("Route hash should not be null").isNotNull(); } } @@ -248,21 +242,21 @@ void getRouteCoverage_should_filter_by_session_metadata() { assertThat(result).as("Response should not be null").isNotNull(); assertThat(result.isSuccess()).as("Response should indicate success").isTrue(); assertThat(result.data()).as("Data should not be null").isNotNull(); - assertThat(result.data().getRoutes()).as("Routes should not be null").isNotNull(); + assertThat(result.data().routes()).as("Routes should not be null").isNotNull(); log.info( "✓ Retrieved {} routes for application: {}", - result.data().getRoutes().size(), + result.data().routes().size(), testData.appName); log.info( " Filtered by session metadata: {}={}", testData.sessionMetadataName, testData.sessionMetadataValue); - // Verify route details are populated - for (Route route : result.data().getRoutes()) { - assertThat(route.getRouteDetailsResponse()) - .as("Route details should be populated for filtered routes") + // Verify routes have essential fields in light format + for (RouteLight route : result.data().routes()) { + assertThat(route.signature()) + .as("Route signature should be present for filtered routes") .isNotNull(); } } @@ -285,24 +279,21 @@ void getRouteCoverage_should_filter_by_latest_session() { .as("Response should indicate success. Application should have session metadata.") .isTrue(); assertThat(result.data()).as("Data should not be null").isNotNull(); - assertThat(result.data().getRoutes()) + assertThat(result.data().routes()) .as("Routes should not be null when success is true") .isNotNull(); - log.info("✓ Retrieved {} routes from latest session", result.data().getRoutes().size()); + log.info("✓ Retrieved {} routes from latest session", result.data().routes().size()); log.info(" Application: {}", testData.appName); - // Count exercised vs discovered - long exercisedCount = - result.data().getRoutes().stream().filter(route -> route.getExercised() > 0).count(); + // Light response includes aggregate statistics + log.info(" Exercised: {}", result.data().exercisedCount()); + log.info(" Discovered: {}", result.data().discoveredCount()); - log.info(" Exercised: {}", exercisedCount); - log.info(" Discovered: {}", (result.data().getRoutes().size() - exercisedCount)); - - // Verify all routes have details - for (Route route : result.data().getRoutes()) { - assertThat(route.getRouteDetailsResponse()) - .as("Route details should be populated for latest session") + // Verify all routes have essential fields in light format + for (RouteLight route : result.data().routes()) { + assertThat(route.signature()) + .as("Route signature should be present for latest session") .isNotNull(); } } @@ -320,11 +311,11 @@ void getRouteCoverage_should_treat_empty_strings_as_null() { assertThat(result.isSuccess()).as("Response should be successful").isTrue(); log.info("✓ Response successful: {}", result.isSuccess()); - log.info("✓ Routes returned: {}", result.data().getRoutes().size()); + log.info("✓ Routes returned: {}", result.data().routes().size()); // Should return routes (assuming the app has route coverage) if (testData.hasRouteCoverage) { - assertThat(result.data().getRoutes().size() > 0) + assertThat(result.data().routes().size() > 0) .as( "Empty strings should return all routes (unfiltered query) when app has route" + " coverage") @@ -368,54 +359,50 @@ void getRouteCoverage_should_handle_nonexistent_metadata_gracefully() { log.info("✓ API handled non-existent metadata gracefully"); log.info( " Routes returned: {} (expected 0 for non-existent metadata)", - result.data().getRoutes().size()); + result.data().routes().size()); // With non-existent metadata, we expect 0 routes - assertThat(result.data().getRoutes().size()) + assertThat(result.data().routes().size()) .as("Non-existent metadata should return 0 routes") .isEqualTo(0); } - // ========== Route details validation ========== + // ========== Route structure validation ========== @Test - void getRouteCoverage_should_populate_route_details_for_all_routes() { - log.info("\n=== Integration Test: Route details structure validation ==="); + void getRouteCoverage_should_populate_route_fields_for_all_routes() { + log.info("\n=== Integration Test: Route light structure validation ==="); var result = getRouteCoverageTool.getRouteCoverage(testData.appId, null, null, null); assertThat(result).as("Response should not be null").isNotNull(); assertThat(result.isSuccess()).as("Response should indicate success").isTrue(); - assertThat(result.data().getRoutes().size() > 0) + assertThat(result.data().routes().size() > 0) .as("Should have at least 1 route to validate") .isTrue(); - log.info("✓ Validating structure of {} routes", result.data().getRoutes().size()); + log.info("✓ Validating structure of {} routes", result.data().routes().size()); - // Validate structure of each route and its details - for (Route route : result.data().getRoutes()) { + // Validate structure of each route in light format + for (RouteLight route : result.data().routes()) { // Route itself should have key fields - assertThat(route.getSignature()) + assertThat(route.signature()) .as("Route signature should be present") .isNotNull() .isNotEmpty(); - assertThat(route.getRouteHash()).as("Route hash should be present").isNotNull().isNotEmpty(); - - // exercised field should be set (can be 0 for discovered routes) - assertThat(route.getExercised()) - .as("Route exercised count should be set (can be 0)") - .isNotNull(); + assertThat(route.routeHash()).as("Route hash should be present").isNotNull().isNotEmpty(); - // Route details should be populated - assertThat(route.getRouteDetailsResponse()) - .as("Route details response should be populated") - .isNotNull(); - assertThat(route.getRouteDetailsResponse().isSuccess()) - .as("Route details should be successfully fetched") - .isTrue(); + // status field should be set + assertThat(route.status()).as("Route status should be present").isNotNull(); } - log.info("✓ All {} routes have valid structure and details", result.data().getRoutes().size()); + // Verify aggregate statistics are computed + assertThat(result.data().totalRoutes()).as("Total routes should be set").isGreaterThan(0); + assertThat(result.data().exercisedCount() + result.data().discoveredCount()) + .as("Exercised + discovered should equal total") + .isEqualTo(result.data().totalRoutes()); + + log.info("✓ All {} routes have valid light structure", result.data().routes().size()); } // ========== Comparison test ========== @@ -447,14 +434,13 @@ void getRouteCoverage_should_return_same_or_more_routes_unfiltered_vs_filtered() assertThat(latestSessionResult.isSuccess()).as("Latest session query should succeed").isTrue(); log.info("✓ All filter types work correctly:"); - log.info(" Unfiltered routes: {}", unfilteredResult.data().getRoutes().size()); - log.info(" Session metadata routes: {}", sessionMetadataResult.data().getRoutes().size()); - log.info(" Latest session routes: {}", latestSessionResult.data().getRoutes().size()); + log.info(" Unfiltered routes: {}", unfilteredResult.data().routes().size()); + log.info(" Session metadata routes: {}", sessionMetadataResult.data().routes().size()); + log.info(" Latest session routes: {}", latestSessionResult.data().routes().size()); // Verify unfiltered should have >= filtered results assertThat( - unfilteredResult.data().getRoutes().size() - >= sessionMetadataResult.data().getRoutes().size()) + unfilteredResult.data().routes().size() >= sessionMetadataResult.data().routes().size()) .as("Unfiltered query should return same or more routes than filtered query") .isTrue(); } diff --git a/src/test/java/com/contrast/labs/ai/mcp/contrast/tool/coverage/GetRouteCoverageToolTest.java b/src/test/java/com/contrast/labs/ai/mcp/contrast/tool/coverage/GetRouteCoverageToolTest.java index 8598f561..dd6eb912 100644 --- a/src/test/java/com/contrast/labs/ai/mcp/contrast/tool/coverage/GetRouteCoverageToolTest.java +++ b/src/test/java/com/contrast/labs/ai/mcp/contrast/tool/coverage/GetRouteCoverageToolTest.java @@ -23,20 +23,21 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockConstruction; import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.contrast.labs.ai.mcp.contrast.config.ContrastSDKFactory; +import com.contrast.labs.ai.mcp.contrast.mapper.RouteMapper; import com.contrast.labs.ai.mcp.contrast.sdkextension.SDKExtension; +import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.Observation; import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.Route; import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.RouteCoverageBySessionIDAndMetadataRequestExtended; import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.RouteCoverageResponse; -import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.RouteDetailsResponse; import com.contrast.labs.ai.mcp.contrast.sdkextension.data.sessionmetadata.AgentSession; import com.contrast.labs.ai.mcp.contrast.sdkextension.data.sessionmetadata.SessionMetadataResponse; import com.contrastsecurity.sdk.ContrastSDK; import java.util.ArrayList; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -55,17 +56,19 @@ class GetRouteCoverageToolTest { private ContrastSDKFactory sdkFactory; private ContrastSDK sdk; private SDKExtension sdkExtension; + private RouteMapper routeMapper; @BeforeEach void setUp() { sdk = mock(); sdkFactory = mock(); sdkExtension = mock(); + routeMapper = new RouteMapper(); when(sdkFactory.getSDK()).thenReturn(sdk); when(sdkFactory.getOrgId()).thenReturn(ORG_ID); - tool = new GetRouteCoverageTool(); + tool = new GetRouteCoverageTool(routeMapper); ReflectionTestUtils.setField(tool, "sdkFactory", sdkFactory); } @@ -111,7 +114,6 @@ void getRouteCoverage_should_collect_multiple_validation_errors() { @Test void getRouteCoverage_should_return_data_when_unfiltered() throws Exception { var mockResponse = createMockRouteCoverageResponse(2); - var routeDetails = createMockRouteDetailsResponse(); try (var mockedConstruction = mockConstruction( @@ -119,8 +121,6 @@ void getRouteCoverage_should_return_data_when_unfiltered() throws Exception { (mock, context) -> { when(mock.getRouteCoverage(eq(ORG_ID), eq(VALID_APP_ID), isNull())) .thenReturn(mockResponse); - when(mock.getRouteDetails(eq(ORG_ID), eq(VALID_APP_ID), anyString())) - .thenReturn(routeDetails); })) { var result = tool.getRouteCoverage(VALID_APP_ID, null, null, null); @@ -128,11 +128,11 @@ void getRouteCoverage_should_return_data_when_unfiltered() throws Exception { assertThat(result.isSuccess()).isTrue(); assertThat(result.found()).isTrue(); assertThat(result.data()).isNotNull(); - assertThat(result.data().getRoutes()).hasSize(2); + assertThat(result.data().routes()).hasSize(2); + // Observations are included inline via expand=observations var constructedMock = mockedConstruction.constructed().get(0); verify(constructedMock).getRouteCoverage(eq(ORG_ID), eq(VALID_APP_ID), isNull()); - verify(constructedMock, times(2)).getRouteDetails(eq(ORG_ID), eq(VALID_APP_ID), anyString()); } } @@ -152,10 +152,7 @@ void getRouteCoverage_should_return_empty_routes_when_none_found() throws Except assertThat(result.isSuccess()).isTrue(); assertThat(result.data()).isNotNull(); - assertThat(result.data().getRoutes()).isEmpty(); - - var constructedMock = mockedConstruction.constructed().get(0); - verify(constructedMock, never()).getRouteDetails(anyString(), anyString(), anyString()); + assertThat(result.data().routes()).isEmpty(); } } @@ -164,7 +161,6 @@ void getRouteCoverage_should_return_empty_routes_when_none_found() throws Except @Test void getRouteCoverage_should_filter_by_session_metadata() throws Exception { var mockResponse = createMockRouteCoverageResponse(1); - var routeDetails = createMockRouteDetailsResponse(); try (var mockedConstruction = mockConstruction( @@ -175,15 +171,13 @@ void getRouteCoverage_should_filter_by_session_metadata() throws Exception { eq(VALID_APP_ID), any(RouteCoverageBySessionIDAndMetadataRequestExtended.class))) .thenReturn(mockResponse); - when(mock.getRouteDetails(eq(ORG_ID), eq(VALID_APP_ID), anyString())) - .thenReturn(routeDetails); })) { var result = tool.getRouteCoverage(VALID_APP_ID, METADATA_NAME, METADATA_VALUE, null); assertThat(result.isSuccess()).isTrue(); assertThat(result.data()).isNotNull(); - assertThat(result.data().getRoutes()).hasSize(1); + assertThat(result.data().routes()).hasSize(1); var constructedMock = mockedConstruction.constructed().get(0); var captor = @@ -204,7 +198,6 @@ void getRouteCoverage_should_filter_by_session_metadata() throws Exception { void getRouteCoverage_should_filter_by_latest_session() throws Exception { var sessionResponse = createMockSessionMetadataResponse(); var mockResponse = createMockRouteCoverageResponse(1); - var routeDetails = createMockRouteDetailsResponse(); try (var mockedConstruction = mockConstruction( @@ -217,15 +210,13 @@ void getRouteCoverage_should_filter_by_latest_session() throws Exception { eq(VALID_APP_ID), any(RouteCoverageBySessionIDAndMetadataRequestExtended.class))) .thenReturn(mockResponse); - when(mock.getRouteDetails(eq(ORG_ID), eq(VALID_APP_ID), anyString())) - .thenReturn(routeDetails); })) { var result = tool.getRouteCoverage(VALID_APP_ID, null, null, true); assertThat(result.isSuccess()).isTrue(); assertThat(result.data()).isNotNull(); - assertThat(result.data().getRoutes()).hasSize(1); + assertThat(result.data().routes()).hasSize(1); var constructedMock = mockedConstruction.constructed().get(0); verify(constructedMock).getLatestSessionMetadata(eq(ORG_ID), eq(VALID_APP_ID)); @@ -310,7 +301,7 @@ void getRouteCoverage_should_handle_null_routes_list() throws Exception { assertThat(result.isSuccess()).isTrue(); assertThat(result.data()).isNotNull(); - assertThat(result.data().getRoutes()).isEmpty(); + assertThat(result.data().routes()).isEmpty(); } } @@ -320,7 +311,6 @@ void getRouteCoverage_should_handle_null_routes_list() throws Exception { void getRouteCoverage_should_add_warning_when_precedence_applies() throws Exception { var sessionResponse = createMockSessionMetadataResponse(); var mockResponse = createMockRouteCoverageResponse(1); - var routeDetails = createMockRouteDetailsResponse(); try (var mockedConstruction = mockConstruction( @@ -333,8 +323,6 @@ void getRouteCoverage_should_add_warning_when_precedence_applies() throws Except eq(VALID_APP_ID), any(RouteCoverageBySessionIDAndMetadataRequestExtended.class))) .thenReturn(mockResponse); - when(mock.getRouteDetails(eq(ORG_ID), eq(VALID_APP_ID), anyString())) - .thenReturn(routeDetails); })) { var result = tool.getRouteCoverage(VALID_APP_ID, METADATA_NAME, METADATA_VALUE, true); @@ -344,12 +332,11 @@ void getRouteCoverage_should_add_warning_when_precedence_applies() throws Except } } - // ========== Route details fetching ========== + // ========== Observations are included inline ========== @Test - void getRouteCoverage_should_fetch_route_details_for_each_route() throws Exception { + void getRouteCoverage_should_return_observations_inline() throws Exception { var mockResponse = createMockRouteCoverageResponse(3); - var routeDetails = createMockRouteDetailsResponse(); try (var mockedConstruction = mockConstruction( @@ -357,22 +344,65 @@ void getRouteCoverage_should_fetch_route_details_for_each_route() throws Excepti (mock, context) -> { when(mock.getRouteCoverage(eq(ORG_ID), eq(VALID_APP_ID), isNull())) .thenReturn(mockResponse); - when(mock.getRouteDetails(eq(ORG_ID), eq(VALID_APP_ID), anyString())) - .thenReturn(routeDetails); })) { var result = tool.getRouteCoverage(VALID_APP_ID, null, null, null); assertThat(result.isSuccess()).isTrue(); - assertThat(result.data().getRoutes()).hasSize(3); + assertThat(result.data().routes()).hasSize(3); - // Verify route details were fetched for each route - for (var route : result.data().getRoutes()) { - assertThat(route.getRouteDetailsResponse()).isNotNull(); + // Verify observations are included inline (from expand=observations) + for (var route : result.data().routes()) { + assertThat(route.observations()).isNotNull(); + assertThat(route.observations()).hasSize(1); + assertThat(route.totalObservations()).isEqualTo(1L); } + } + } - var constructedMock = mockedConstruction.constructed().get(0); - verify(constructedMock, times(3)).getRouteDetails(eq(ORG_ID), eq(VALID_APP_ID), anyString()); + // ========== Light response transformation tests ========== + + @Test + void getRouteCoverage_should_return_light_response_with_aggregate_statistics() throws Exception { + var mockResponse = createMockRouteCoverageResponse(4); + // Set status on routes: 2 exercised (even indices), 2 discovered (odd indices) + mockResponse.getRoutes().get(0).setStatus("EXERCISED"); + mockResponse.getRoutes().get(1).setStatus("DISCOVERED"); + mockResponse.getRoutes().get(2).setStatus("EXERCISED"); + mockResponse.getRoutes().get(3).setStatus("DISCOVERED"); + // Add vulnerability counts + mockResponse.getRoutes().get(0).setVulnerabilities(2); + mockResponse.getRoutes().get(0).setCriticalVulnerabilities(1); + mockResponse.getRoutes().get(1).setVulnerabilities(3); + mockResponse.getRoutes().get(1).setCriticalVulnerabilities(2); + + try (var mockedConstruction = + mockConstruction( + SDKExtension.class, + (mock, context) -> { + when(mock.getRouteCoverage(eq(ORG_ID), eq(VALID_APP_ID), isNull())) + .thenReturn(mockResponse); + })) { + + var result = tool.getRouteCoverage(VALID_APP_ID, null, null, null); + + assertThat(result.isSuccess()).isTrue(); + var lightResponse = result.data(); + + // Verify aggregate statistics are computed + assertThat(lightResponse.totalRoutes()).isEqualTo(4); + assertThat(lightResponse.exercisedCount()).isEqualTo(2); + assertThat(lightResponse.discoveredCount()).isEqualTo(2); + assertThat(lightResponse.coveragePercent()).isEqualTo(50.0); + assertThat(lightResponse.totalVulnerabilities()).isEqualTo(5); + assertThat(lightResponse.totalCriticalVulnerabilities()).isEqualTo(3); + + // Verify routes are transformed to light format + assertThat(lightResponse.routes()).hasSize(4); + var firstRoute = lightResponse.routes().get(0); + assertThat(firstRoute.signature()).isEqualTo("GET /api/endpoint0"); + assertThat(firstRoute.routeHash()).isEqualTo(ROUTE_HASH + "-0"); + assertThat(firstRoute.status()).isEqualTo("EXERCISED"); } } @@ -388,6 +418,14 @@ private RouteCoverageResponse createMockRouteCoverageResponse(int routeCount) { route.setSignature("GET /api/endpoint" + i); route.setRouteHash(ROUTE_HASH + "-" + i); route.setExercised(i % 2 == 0 ? 1L : 0L); + + // Include observations inline (simulating expand=observations response) + var observation = new Observation(); + observation.setVerb("GET"); + observation.setUrl("/api/endpoint" + i); + route.setObservations(List.of(observation)); + route.setTotalObservations(1L); + routes.add(route); } @@ -395,12 +433,6 @@ private RouteCoverageResponse createMockRouteCoverageResponse(int routeCount) { return response; } - private RouteDetailsResponse createMockRouteDetailsResponse() { - var response = new RouteDetailsResponse(); - response.setSuccess(true); - return response; - } - private SessionMetadataResponse createMockSessionMetadataResponse() { var response = new SessionMetadataResponse(); var session = new AgentSession(); diff --git a/src/test/java/com/contrast/labs/ai/mcp/contrast/tool/validation/MetadataJsonFilterSpecTest.java b/src/test/java/com/contrast/labs/ai/mcp/contrast/tool/validation/MetadataJsonFilterSpecTest.java index 6acc0ddf..1689cfe4 100644 --- a/src/test/java/com/contrast/labs/ai/mcp/contrast/tool/validation/MetadataJsonFilterSpecTest.java +++ b/src/test/java/com/contrast/labs/ai/mcp/contrast/tool/validation/MetadataJsonFilterSpecTest.java @@ -64,6 +64,15 @@ void get_should_return_null_for_empty_json_object() { assertThat(ctx.isValid()).isTrue(); } + @Test + void get_should_add_error_for_empty_array_value() { + var ctx = new ToolValidationContext(); + var result = ctx.metadataJsonFilterParam("{\"field\":[]}", "test").get(); + assertThat(result).isNull(); + assertThat(ctx.isValid()).isFalse(); + assertThat(ctx.errors()).anyMatch(e -> e.contains("field") && e.contains("empty array")); + } + @Test void get_should_add_error_for_invalid_json() { var ctx = new ToolValidationContext(); diff --git a/src/test/java/com/contrast/labs/ai/mcp/contrast/utils/PaginationHandlerTest.java b/src/test/java/com/contrast/labs/ai/mcp/contrast/utils/PaginationHandlerTest.java deleted file mode 100644 index 29cc46a8..00000000 --- a/src/test/java/com/contrast/labs/ai/mcp/contrast/utils/PaginationHandlerTest.java +++ /dev/null @@ -1,221 +0,0 @@ -/* - * Copyright 2025 Contrast Security - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.contrast.labs.ai.mcp.contrast.utils; - -import static org.assertj.core.api.Assertions.assertThat; - -import com.contrast.labs.ai.mcp.contrast.PaginationParams; -import java.util.List; -import java.util.stream.Collectors; -import java.util.stream.IntStream; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -/** Tests for PaginationHandler component. */ -class PaginationHandlerTest { - - private PaginationHandler handler; - - @BeforeEach - void setUp() { - handler = new PaginationHandler(); - } - - // ========== createPaginatedResponse Tests ========== - - @Test - void testCreatePaginatedResponse_withTotalCount_firstPage() { - var items = List.of("item1", "item2", "item3"); - var params = PaginationParams.of(1, 3); - - var response = handler.createPaginatedResponse(items, params, 10); - - assertThat(response.items().size()).isEqualTo(3); - assertThat(response.page()).isEqualTo(1); - assertThat(response.pageSize()).isEqualTo(3); - assertThat(response.totalItems()).isEqualTo(10); - assertThat(response.hasMorePages()) - .as("Should have more pages when 3 items fetched out of 10") - .isTrue(); - assertThat(response.errors()).as("No errors for successful page").isEmpty(); - assertThat(response.warnings()).as("No warnings for successful page").isEmpty(); - } - - @Test - void testCreatePaginatedResponse_withTotalCount_lastPage() { - var items = List.of("item8", "item9", "item10"); - var params = PaginationParams.of(4, 3); - - var response = handler.createPaginatedResponse(items, params, 10); - - assertThat(response.items().size()).isEqualTo(3); - assertThat(response.page()).isEqualTo(4); - assertThat(response.pageSize()).isEqualTo(3); - assertThat(response.totalItems()).isEqualTo(10); - assertThat(response.hasMorePages()) - .as("Should not have more pages (page 4 * pageSize 3 = 12 >= 10)") - .isFalse(); - assertThat(response.errors()).isEmpty(); - assertThat(response.warnings()).isEmpty(); - } - - @Test - void testCreatePaginatedResponse_withoutTotalCount_fullPage() { - var items = List.of("item1", "item2", "item3"); - var params = PaginationParams.of(1, 3); - - var response = handler.createPaginatedResponse(items, params, null); - - assertThat(response.items().size()).isEqualTo(3); - assertThat(response.totalItems()).isNull(); - assertThat(response.hasMorePages()) - .as("Full page without totalCount suggests more pages exist (heuristic)") - .isTrue(); - } - - @Test - void testCreatePaginatedResponse_withoutTotalCount_partialPage() { - var items = List.of("item1", "item2"); - var params = PaginationParams.of(1, 3); - - var response = handler.createPaginatedResponse(items, params, null); - - assertThat(response.items().size()).isEqualTo(2); - assertThat(response.totalItems()).isNull(); - assertThat(response.hasMorePages()) - .as("Partial page suggests no more pages (heuristic)") - .isFalse(); - } - - @Test - void testCreatePaginatedResponse_emptyFirstPage() { - var items = List.of(); - var params = PaginationParams.of(1, 10); - - var response = handler.createPaginatedResponse(items, params, 0); - - assertThat(response.items()).isEmpty(); - assertThat(response.totalItems()).isEqualTo(0); - assertThat(response.hasMorePages()).isFalse(); - assertThat(response.warnings()).containsExactly("No items found."); - } - - @Test - void testCreatePaginatedResponse_emptySecondPage_withTotalCount() { - var items = List.of(); - var params = PaginationParams.of(2, 10); - - var response = handler.createPaginatedResponse(items, params, 5); - - assertThat(response.items()).isEmpty(); - assertThat(response.totalItems()).isEqualTo(5); - assertThat(response.hasMorePages()).isFalse(); - assertThat(response.warnings()) - .anyMatch(w -> w.contains("Requested page 2 exceeds available pages (total: 1)")); - } - - @Test - void testCreatePaginatedResponse_emptySecondPage_withoutTotalCount() { - var items = List.of(); - var params = PaginationParams.of(2, 10); - - var response = handler.createPaginatedResponse(items, params, null); - - assertThat(response.items()).isEmpty(); - assertThat(response.totalItems()).isNull(); - assertThat(response.hasMorePages()).isFalse(); - assertThat(response.warnings()) - .anyMatch(w -> w.contains("Requested page 2 returned no results")); - } - - @Test - void testCreatePaginatedResponse_mergesWarnings() { - var items = List.of("item1"); - // Create params with warning (invalid page clamped to 1) - var params = PaginationParams.of(-5, 10); - - var response = handler.createPaginatedResponse(items, params, 1); - - assertThat(response.warnings()).isNotEmpty(); - assertThat(response.warnings()) - .as("Should include pagination warning") - .anyMatch(w -> w.contains("Invalid page number -5")); - } - - @Test - void testCreatePaginatedResponse_mergesWarningsWithEmptyMessage() { - var items = List.of(); - // Create params with warning (invalid pageSize) - var params = PaginationParams.of(2, 200); - - var response = handler.createPaginatedResponse(items, params, 5); - - assertThat(response.warnings()).isNotEmpty(); - assertThat(response.warnings()) - .as("Should include pageSize warning") - .anyMatch(w -> w.contains("Requested pageSize 200 exceeds maximum 100")); - assertThat(response.warnings()) - .as("Should include empty page message") - .anyMatch(w -> w.contains("Requested page 2 exceeds available pages")); - } - - // ========== Edge Cases ========== - - @Test - void testHasMorePages_boundaryCase() { - // Exactly 20 items, page 2, pageSize 10 - // Page 2 * 10 = 20, which equals totalItems, so no more pages - var items = List.of("item10", "item11"); - var params = PaginationParams.of(2, 10); - - var response = handler.createPaginatedResponse(items, params, 20); - - assertThat(response.hasMorePages()) - .as("No more pages when page*pageSize == totalItems") - .isFalse(); - } - - @Test - void testHasMorePages_justOverBoundary() { - // 21 items, page 2, pageSize 10 - // Page 2 * 10 = 20 < 21, so more pages exist - var items = List.of("item10", "item11"); - var params = PaginationParams.of(2, 10); - - var response = handler.createPaginatedResponse(items, params, 21); - - assertThat(response.hasMorePages()).as("More pages when page*pageSize < totalItems").isTrue(); - } - - @Test - void testMessagePriority_warningAndEmpty() { - // Both warning and empty message should appear - var items = List.of(); - var params = PaginationParams.of(-1, 10); // Invalid page - - var response = handler.createPaginatedResponse(items, params, 0); - - assertThat(response.warnings()).isNotEmpty(); - assertThat(response.warnings()).anyMatch(w -> w.contains("Invalid page number")); - assertThat(response.warnings()).anyMatch(w -> w.contains("No items found")); - } - - // ========== Helper Methods ========== - - private List createItems(int count) { - return IntStream.range(0, count).mapToObj(i -> "item" + i).collect(Collectors.toList()); - } -}