diff --git a/CLAUDE.md b/CLAUDE.md index 9aa9755..2d9521c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,6 +94,44 @@ This codebase handles sensitive vulnerability data. The README contains critical - Console logging is minimal by design for MCP protocol compatibility - Debug mode buffers API responses for logging (memory impact with large datasets) +## Beads Workflow Requirements + +This project uses Beads (bd) for issue tracking. See the MCP resource `beads://quickstart` for usage details. + +### Bead Status Management + +**IMPORTANT: Update bead status as you work:** + +1. **When starting work on a bead**: Immediately set status to `in_progress` + ``` + bd update status=in_progress + ``` + Or use the MCP tool: + ``` + mcp__plugin_beads_beads__update(issue_id="", status="in_progress") + ``` + +2. **While working**: Keep the bead `in_progress` until all work is complete, tested, and ready to close + +3. **When work is complete**: Close the bead only after all acceptance criteria are met + ``` + bd close + ``` + +**Status lifecycle:** +- `open` → Task not yet started +- `in_progress` → Actively working on task (SET THIS WHEN YOU START!) +- `closed` → Task complete, tested, and merged + +### Managing Bead Dependencies + +**Command syntax:** `bd dep add ` + +Example: If B must be done after A completes, use `bd dep add B A` (not `bd dep add A B`). + +Verify with `bd show ` - dependent tasks show "Depends on", prerequisites show "Blocks". + +### Testing Requirements Before Closing Beads ### Troubleshooting For common issues (SSL certificates, proxy configuration, debug logging), see the "Common Issues" and "Proxy Configuration" sections in [README.md](README.md). diff --git a/src/main/java/com/contrast/labs/ai/mcp/contrast/RouteCoverageService.java b/src/main/java/com/contrast/labs/ai/mcp/contrast/RouteCoverageService.java index fe1a269..e7b4d0f 100644 --- a/src/main/java/com/contrast/labs/ai/mcp/contrast/RouteCoverageService.java +++ b/src/main/java/com/contrast/labs/ai/mcp/contrast/RouteCoverageService.java @@ -2,13 +2,11 @@ import com.contrast.labs.ai.mcp.contrast.sdkexstension.SDKExtension; import com.contrast.labs.ai.mcp.contrast.sdkexstension.SDKHelper; -import com.contrast.labs.ai.mcp.contrast.sdkexstension.data.application.Application; import com.contrast.labs.ai.mcp.contrast.sdkexstension.data.routecoverage.Route; import com.contrast.labs.ai.mcp.contrast.sdkexstension.data.routecoverage.RouteCoverageBySessionIDAndMetadataRequestExtended; import com.contrast.labs.ai.mcp.contrast.sdkexstension.data.routecoverage.RouteCoverageResponse; import com.contrast.labs.ai.mcp.contrast.sdkexstension.data.routecoverage.RouteDetailsResponse; import com.contrast.labs.ai.mcp.contrast.sdkexstension.data.sessionmetadata.SessionMetadataResponse; -import com.contrastsecurity.models.RouteCoverageBySessionIDAndMetadataRequest; import com.contrastsecurity.models.RouteCoverageMetadataLabelValues; import com.contrastsecurity.sdk.ContrastSDK; import org.slf4j.Logger; @@ -18,15 +16,12 @@ import org.springframework.stereotype.Service; import java.io.IOException; -import java.util.Optional; @Service public class RouteCoverageService { private static final Logger logger = LoggerFactory.getLogger(RouteCoverageService.class); - - @Value("${contrast.host-name:${CONTRAST_HOST_NAME:}}") private String hostName; @@ -48,150 +43,94 @@ public class RouteCoverageService { @Value("${http.proxy.port:${http_proxy_port:}}") private String httpProxyPort; - - - @Tool(name = "get_application_route_coverage", description = "takes a application name and return the route coverage data for that application. " + - "If a route/endpoint is DISCOVERED, it means it has been found by Assess but that route has had no inbound http requests. If it is EXERCISED, it means it has had atleast one inbound http request to that route/endpoint.") - public RouteCoverageResponse getRouteCoverage(String app_name) throws IOException { - logger.info("Retrieving route coverage for application by name: {}", app_name); - ContrastSDK contrastSDK = SDKHelper.getSDK(hostName, apiKey, serviceKey, userName,httpProxyHost, httpProxyPort); - SDKExtension sdkExtension = new SDKExtension(contrastSDK); - logger.debug("Searching for application ID matching name: {}", app_name); - - Optional application = SDKHelper.getApplicationByName(app_name, orgID, contrastSDK); - - if (!application.isPresent()) { - logger.error("Application not found: {}", app_name); - throw new IOException("Application not found: " + app_name); - } - - logger.debug("Fetching route coverage data for application ID: {}", application.get().getAppId()); - RouteCoverageResponse response = sdkExtension.getRouteCoverage(orgID, application.get().getAppId(), null); - logger.debug("Found {} routes for application", response.getRoutes().size()); - - logger.debug("Retrieving route details for each route"); - for(Route route : response.getRoutes()) { - logger.trace("Fetching details for route: {}", route.getSignature()); - RouteDetailsResponse routeDetailsResponse = sdkExtension.getRouteDetails(orgID, application.get().getAppId(), route.getRouteHash()); - route.setRouteDetailsResponse(routeDetailsResponse); + /** + * Retrieves route coverage data for an application with optional filtering. + * + * Routes can have two statuses: + * - DISCOVERED: Found by Contrast Assess but has not received any HTTP requests + * - EXERCISED: Has received at least one HTTP request + * + * @param appId Required - The application ID to retrieve route coverage for + * @param sessionMetadataName Optional - Filter by session metadata field name (e.g., "branch"). + * Empty strings are treated as null (no filter). + * @param sessionMetadataValue Optional - Filter by session metadata field value (e.g., "main"). + * Required if sessionMetadataName is provided. Empty strings are treated as null. + * @param useLatestSession Optional - If true, only return routes from the latest session + * @return RouteCoverageResponse containing route coverage data with details for each route + * @throws IOException If an error occurs while retrieving data from Contrast + * @throws IllegalArgumentException If sessionMetadataName is provided without sessionMetadataValue + */ + @Tool(name = "get_route_coverage", + description = "Retrieves route coverage data for an application. Routes can be DISCOVERED (found but not exercised) " + + "or EXERCISED (received HTTP traffic). All filter parameters are truly optional - if none provided (null or empty strings), " + + "returns all routes across all sessions. Parameters: appId (required), sessionMetadataName (optional), " + + "sessionMetadataValue (optional - required if sessionMetadataName provided), useLatestSession (optional).") + public RouteCoverageResponse getRouteCoverage( + String appId, + String sessionMetadataName, + String sessionMetadataValue, + Boolean useLatestSession) throws IOException { + + logger.info("Retrieving route coverage for application ID: {}", appId); + + // Validate parameters - treat empty strings as null + if (sessionMetadataName != null && !sessionMetadataName.isEmpty() && + (sessionMetadataValue == null || sessionMetadataValue.isEmpty())) { + String errorMsg = "sessionMetadataValue is required when sessionMetadataName is provided"; + logger.error(errorMsg); + throw new IllegalArgumentException(errorMsg); } - logger.info("Successfully retrieved route coverage for application: {}", app_name); - return response; - } - - @Tool(name = "get_application_route_coverage_by_app_id", description = "takes a application id and return the route coverage data for that application. " + - "If a route/endpoint is DISCOVERED, it means it has been found by Assess but that route has had no inbound http requests. If it is EXERCISED, it means it has had atleast one inbound http request to that route/endpoint.") - public RouteCoverageResponse getRouteCoverageByAppID(String app_id) throws IOException { - logger.info("Retrieving route coverage for application by ID: {}", app_id); - ContrastSDK contrastSDK = SDKHelper.getSDK(hostName, apiKey, serviceKey, userName,httpProxyHost, httpProxyPort); - SDKExtension sdkExtension = new SDKExtension(contrastSDK); - - logger.debug("Fetching route coverage data for application ID: {}", app_id); - RouteCoverageResponse response = sdkExtension.getRouteCoverage(orgID, app_id, null); - logger.debug("Found {} routes for application", response.getRoutes().size()); - - logger.debug("Retrieving route details for each route"); - for(Route route : response.getRoutes()) { - logger.trace("Fetching details for route: {}", route.getSignature()); - RouteDetailsResponse routeDetailsResponse = sdkExtension.getRouteDetails(orgID, app_id, route.getRouteHash()); - route.setRouteDetailsResponse(routeDetailsResponse); - } - - logger.info("Successfully retrieved route coverage for application ID: {}", app_id); - return response; - } - - @Tool(name = "get_application_route_coverage_by_app_name_and_session_metadata", description = "takes a application name and return the route coverage data for that application for the specified session metadata name and value. " + - "If a route/endpoint is DISCOVERED, it means it has been found by Assess but that route has had no inbound http requests. If it is EXERCISED, it means it has had at least one inbound http request to that route/endpoint.") - public RouteCoverageResponse getRouteCoverageByAppNameAndSessionMetadata(String app_name, String session_Metadata_Name, String session_Metadata_Value) throws IOException { - logger.info("Retrieving route coverage for application by Name: {}", app_name); - ContrastSDK contrastSDK = SDKHelper.getSDK(hostName, apiKey, serviceKey, userName,httpProxyHost, httpProxyPort); - logger.debug("Searching for application ID matching name: {}", app_name); - - Optional application = SDKHelper.getApplicationByName(app_name, orgID, contrastSDK); - if (!application.isPresent()) { - logger.error("Application not found: {}", app_name); - throw new IOException("Application not found: " + app_name); - } - return getRouteCoverageByAppIDAndSessionMetadata(application.get().getAppId(), session_Metadata_Name, session_Metadata_Value); - } - - @Tool(name = "get_application_route_coverage_by_app_id_and_session_metadata", description = "takes a application id and return the route coverage data for that application for the specified session metadata name and value. " + - "If a route/endpoint is DISCOVERED, it means it has been found by Assess but that route has had no inbound http requests. If it is EXERCISED, it means it has had at least one inbound http request to that route/endpoint.") - public RouteCoverageResponse getRouteCoverageByAppIDAndSessionMetadata(String app_id, String session_Metadata_Name, String session_Metadata_Value) throws IOException { - logger.info("Retrieving route coverage for application by ID: {}", app_id); - ContrastSDK contrastSDK = SDKHelper.getSDK(hostName, apiKey, serviceKey, userName,httpProxyHost, httpProxyPort); + // Initialize SDK + ContrastSDK contrastSDK = SDKHelper.getSDK(hostName, apiKey, serviceKey, userName, httpProxyHost, httpProxyPort); SDKExtension sdkExtension = new SDKExtension(contrastSDK); - RouteCoverageBySessionIDAndMetadataRequestExtended requestExtended = new RouteCoverageBySessionIDAndMetadataRequestExtended(); - RouteCoverageMetadataLabelValues metadataLabelValue = new RouteCoverageMetadataLabelValues(); - metadataLabelValue.setLabel(session_Metadata_Name); - metadataLabelValue.getValues().add(String.valueOf(session_Metadata_Value)); - requestExtended.getValues().add(metadataLabelValue); - logger.debug("Fetching route coverage data for application ID: {}", app_id); - RouteCoverageResponse response = sdkExtension.getRouteCoverage(orgID, app_id, requestExtended); - logger.debug("Found {} routes for application", response.getRoutes().size()); - - logger.debug("Retrieving route details for each route"); - for(Route route : response.getRoutes()) { - logger.trace("Fetching details for route: {}", route.getSignature()); - RouteDetailsResponse routeDetailsResponse = sdkExtension.getRouteDetails(orgID, app_id, route.getRouteHash()); - route.setRouteDetailsResponse(routeDetailsResponse); - } - - logger.info("Successfully retrieved route coverage for application ID: {}", app_id); - return response; - } - @Tool(name = "get_application_route_coverage_by_app_name_latest_session", description = "takes a application name and return the route coverage data for that application from the latest session. " + - "If a route/endpoint is DISCOVERED, it means it has been found by Assess but that route has had no inbound http requests. If it is EXERCISED, it means it has had atleast one inbound http request to that route/endpoint.") - public RouteCoverageResponse getRouteCoverageByAppNameLatestSession(String app_name) throws IOException { - logger.info("Retrieving route coverage for application by Name: {}", app_name); - ContrastSDK contrastSDK = SDKHelper.getSDK(hostName, apiKey, serviceKey, userName, httpProxyHost, httpProxyPort); - Optional application = SDKHelper.getApplicationByName(app_name, orgID, contrastSDK); - if (application.isEmpty()) { - logger.error("Application not found: {}", app_name); - throw new IOException("Application not found: " + app_name); + // Build request based on parameters + RouteCoverageBySessionIDAndMetadataRequestExtended requestExtended = null; + + if (useLatestSession != null && useLatestSession) { + // Filter by latest session + logger.debug("Fetching latest session metadata for application ID: {}", appId); + SessionMetadataResponse latest = sdkExtension.getLatestSessionMetadata(orgID, appId); + + if (latest == null || latest.getAgentSession() == null) { + logger.error("No session metadata found for application ID: {}", appId); + RouteCoverageResponse noRouteCoverageResponse = new RouteCoverageResponse(); + noRouteCoverageResponse.setSuccess(false); + logger.debug("No Agent session found in latest session metadata response for application ID: {}", appId); + return noRouteCoverageResponse; + } + + requestExtended = new RouteCoverageBySessionIDAndMetadataRequestExtended(); + requestExtended.setSessionId(latest.getAgentSession().getAgentSessionId()); + logger.debug("Using latest session ID: {}", latest.getAgentSession().getAgentSessionId()); + + } else if (sessionMetadataName != null && !sessionMetadataName.isEmpty()) { + // Filter by session metadata + logger.debug("Filtering by session metadata: {}={}", sessionMetadataName, sessionMetadataValue); + requestExtended = new RouteCoverageBySessionIDAndMetadataRequestExtended(); + RouteCoverageMetadataLabelValues metadataLabelValue = new RouteCoverageMetadataLabelValues(); + metadataLabelValue.setLabel(sessionMetadataName); + metadataLabelValue.getValues().add(sessionMetadataValue); + requestExtended.getValues().add(metadataLabelValue); + } else { + logger.debug("No filters applied - retrieving all route coverage"); } - return getRouteCoverageByAppIDLatestSession(application.get().getAppId()); - } - - @Tool(name = "get_application_route_coverage_by_app_id_latest_session", description = "takes a application id and return the route coverage data for that application from the latest session. " + - "If a route/endpoint is DISCOVERED, it means it has been found by Assess but that route has had no inbound http requests. If it is EXERCISED, it means it has had atleast one inbound http request to that route/endpoint.") - public RouteCoverageResponse getRouteCoverageByAppIDLatestSession(String app_id) throws IOException { - logger.info("Retrieving route coverage for application by ID: {}", app_id); - ContrastSDK contrastSDK = SDKHelper.getSDK(hostName, apiKey, serviceKey, userName,httpProxyHost, httpProxyPort); - SDKExtension sdkExtension = new SDKExtension(contrastSDK); - SDKExtension extension = new SDKExtension(contrastSDK); - SessionMetadataResponse latest = extension.getLatestSessionMetadata(orgID,app_id); - if (latest == null || latest.getAgentSession() == null) { - logger.error("No session metadata found for application ID: {}", app_id); - RouteCoverageResponse noRouteCoverageResponse = new RouteCoverageResponse(); - noRouteCoverageResponse.setSuccess(Boolean.FALSE); - logger.debug("No Agent session found in latest session metadata response for application ID: {}", app_id); - return noRouteCoverageResponse; // Return empty response if no session metadata found - } - RouteCoverageBySessionIDAndMetadataRequestExtended requestExtended = new RouteCoverageBySessionIDAndMetadataRequestExtended(); - requestExtended.setSessionId(latest.getAgentSession().getAgentSessionId()); - logger.debug("Fetching route coverage data for application ID: {}", app_id); - RouteCoverageResponse response = sdkExtension.getRouteCoverage(orgID, app_id, requestExtended); + // Call SDK to get route coverage + logger.debug("Fetching route coverage data for application ID: {}", appId); + RouteCoverageResponse response = sdkExtension.getRouteCoverage(orgID, appId, requestExtended); logger.debug("Found {} routes for application", response.getRoutes().size()); + // Fetch route details for each route logger.debug("Retrieving route details for each route"); - for(Route route : response.getRoutes()) { + for (Route route : response.getRoutes()) { logger.trace("Fetching details for route: {}", route.getSignature()); - RouteDetailsResponse routeDetailsResponse = sdkExtension.getRouteDetails(orgID, app_id, route.getRouteHash()); + RouteDetailsResponse routeDetailsResponse = sdkExtension.getRouteDetails(orgID, appId, route.getRouteHash()); route.setRouteDetailsResponse(routeDetailsResponse); } - logger.info("Successfully retrieved route coverage for application ID: {}", app_id); + logger.info("Successfully retrieved route coverage for application ID: {} ({} routes)", appId, response.getRoutes().size()); return response; } - - - - - - } diff --git a/src/test/java/com/contrast/labs/ai/mcp/contrast/RouteCoverageServiceIntegrationTest.java b/src/test/java/com/contrast/labs/ai/mcp/contrast/RouteCoverageServiceIntegrationTest.java new file mode 100644 index 0000000..a9ffef9 --- /dev/null +++ b/src/test/java/com/contrast/labs/ai/mcp/contrast/RouteCoverageServiceIntegrationTest.java @@ -0,0 +1,555 @@ +/* + * 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; + +import com.contrast.labs.ai.mcp.contrast.sdkexstension.SDKExtension; +import com.contrast.labs.ai.mcp.contrast.sdkexstension.SDKHelper; +import com.contrast.labs.ai.mcp.contrast.sdkexstension.data.application.Application; +import com.contrast.labs.ai.mcp.contrast.sdkexstension.data.application.ApplicationsResponse; +import com.contrast.labs.ai.mcp.contrast.sdkexstension.data.routecoverage.Route; +import com.contrast.labs.ai.mcp.contrast.sdkexstension.data.routecoverage.RouteCoverageResponse; +import com.contrast.labs.ai.mcp.contrast.sdkexstension.data.sessionmetadata.MetadataSession; +import com.contrast.labs.ai.mcp.contrast.sdkexstension.data.sessionmetadata.SessionMetadataResponse; +import com.contrastsecurity.sdk.ContrastSDK; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; + +import java.io.IOException; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Integration test for RouteCoverageService that validates route coverage data from real TeamServer. + * + * This test automatically discovers suitable test data by querying the Contrast API. + * It looks for applications with route coverage, sessions, and session metadata. + * + * This test only runs if CONTRAST_HOST_NAME environment variable is set. + * + * Required environment variables: + * - CONTRAST_HOST_NAME (e.g., app.contrastsecurity.com) + * - CONTRAST_API_KEY + * - CONTRAST_SERVICE_KEY + * - CONTRAST_USERNAME + * - CONTRAST_ORG_ID + * + * Run locally: + * source .env.integration-test # Load credentials + * mvn verify + * + * Or skip integration tests: + * mvn verify -DskipITs + */ +@SpringBootTest +@EnabledIfEnvironmentVariable(named = "CONTRAST_HOST_NAME", matches = ".+") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class RouteCoverageServiceIntegrationTest { + + @Autowired + private RouteCoverageService routeCoverageService; + + @Value("${contrast.host-name:${CONTRAST_HOST_NAME:}}") + private String hostName; + + @Value("${contrast.api-key:${CONTRAST_API_KEY:}}") + private String apiKey; + + @Value("${contrast.service-key:${CONTRAST_SERVICE_KEY:}}") + private String serviceKey; + + @Value("${contrast.username:${CONTRAST_USERNAME:}}") + private String userName; + + @Value("${contrast.org-id:${CONTRAST_ORG_ID:}}") + private String orgID; + + @Value("${http.proxy.host:${http_proxy_host:}}") + private String httpProxyHost; + + @Value("${http.proxy.port:${http_proxy_port:}}") + private String httpProxyPort; + + // Discovered test data - populated in @BeforeAll + private static TestData testData; + + /** + * Container for discovered test data + */ + private static class TestData { + String appId; + String appName; + boolean hasRouteCoverage; + boolean hasSessionMetadata; + String sessionMetadataName; + String sessionMetadataValue; + int routeCount; + + @Override + public String toString() { + return String.format( + "TestData{appId='%s', appName='%s', hasRouteCoverage=%s, hasSessionMetadata=%s, " + + "sessionMetadataName='%s', sessionMetadataValue='%s', routeCount=%d}", + appId, appName, hasRouteCoverage, hasSessionMetadata, + sessionMetadataName, sessionMetadataValue, routeCount + ); + } + } + + @BeforeAll + void discoverTestData() { + System.out.println("\n╔════════════════════════════════════════════════════════════════════════════════╗"); + System.out.println("║ Route Coverage Integration Test - Discovering Test Data ║"); + System.out.println("╚════════════════════════════════════════════════════════════════════════════════╝"); + + try { + ContrastSDK sdk = SDKHelper.getSDK(hostName, apiKey, serviceKey, userName, httpProxyHost, httpProxyPort); + SDKExtension sdkExtension = new SDKExtension(sdk); + + // Get all applications + System.out.println("\n🔍 Step 1: Fetching all applications..."); + ApplicationsResponse appsResponse = sdkExtension.getApplications(orgID); + List applications = appsResponse.getApplications(); + System.out.println(" Found " + applications.size() + " application(s) in organization"); + + if (applications.isEmpty()) { + System.out.println("\n⚠️ NO APPLICATIONS FOUND"); + System.out.println(" The integration tests require at least one application with:"); + System.out.println(" 1. Route coverage data (routes discovered or exercised)"); + System.out.println(" 2. Session metadata (optional but recommended)"); + System.out.println("\n To create test data:"); + System.out.println(" - Deploy an application with Contrast agent"); + System.out.println(" - Exercise some routes (make HTTP requests)"); + System.out.println(" - Optionally: Configure session metadata in agent"); + return; + } + + // Search for suitable test application - prioritize apps with BOTH routes AND session metadata + System.out.println("\n🔍 Step 2: Searching for application with route coverage AND session metadata..."); + TestData bestCandidate = null; + TestData fallbackCandidate = null; // App with routes but no session metadata + int appsChecked = 0; + int maxAppsToCheck = Math.min(applications.size(), 50); // Check up to 50 apps + + for (Application app : applications) { + if (appsChecked >= maxAppsToCheck) { + System.out.println(" Reached max apps to check (" + maxAppsToCheck + "), stopping search"); + break; + } + appsChecked++; + + System.out.println(" Checking app " + appsChecked + "/" + maxAppsToCheck + ": " + + app.getName() + " (ID: " + app.getAppId() + ")"); + + try { + // Check for route coverage + RouteCoverageResponse routeResponse = sdkExtension.getRouteCoverage(orgID, app.getAppId(), null); + if (routeResponse != null && routeResponse.getRoutes() != null && !routeResponse.getRoutes().isEmpty()) { + System.out.println(" ✓ Has " + routeResponse.getRoutes().size() + " route(s)"); + + TestData candidate = new TestData(); + candidate.appId = app.getAppId(); + candidate.appName = app.getName(); + candidate.hasRouteCoverage = true; + candidate.routeCount = routeResponse.getRoutes().size(); + + // Check for session metadata + try { + SessionMetadataResponse sessionResponse = sdkExtension.getLatestSessionMetadata(orgID, app.getAppId()); + if (sessionResponse != null && sessionResponse.getAgentSession() != null) { + // Try to extract a metadata field from metadataSessions list + if (sessionResponse.getAgentSession().getMetadataSessions() != null && + !sessionResponse.getAgentSession().getMetadataSessions().isEmpty()) { + var firstMetadata = sessionResponse.getAgentSession().getMetadataSessions().get(0); + if (firstMetadata.getMetadataField() != null && + firstMetadata.getMetadataField().getAgentLabel() != null && + firstMetadata.getValue() != null) { + candidate.hasSessionMetadata = true; + candidate.sessionMetadataName = firstMetadata.getMetadataField().getAgentLabel(); + candidate.sessionMetadataValue = firstMetadata.getValue(); + System.out.println(" ✓ Has session metadata"); + System.out.println(" ✓ Session metadata field: " + + candidate.sessionMetadataName + "=" + candidate.sessionMetadataValue); + + // Found perfect candidate with both routes and session metadata! + System.out.println("\n ✅ Found PERFECT test application with routes AND session metadata!"); + bestCandidate = candidate; + break; // Stop searching - we found what we need + } + } + } + } catch (Exception e) { + System.out.println(" ℹ No session metadata: " + e.getMessage()); + } + + // Save as fallback if we haven't found a perfect candidate yet + if (!candidate.hasSessionMetadata && fallbackCandidate == null) { + System.out.println(" ℹ Saving as fallback candidate (has routes but no session metadata)"); + fallbackCandidate = candidate; + } + } + } catch (Exception e) { + // Skip this app, continue searching + System.out.println(" ℹ No route coverage or error: " + e.getMessage()); + } + } + + // Determine which candidate to use + TestData candidate = bestCandidate != null ? bestCandidate : fallbackCandidate; + + if (candidate != null) { + testData = candidate; + System.out.println("\n╔════════════════════════════════════════════════════════════════════════════════╗"); + System.out.println("║ Test Data Discovery Complete ║"); + System.out.println("╚════════════════════════════════════════════════════════════════════════════════╝"); + System.out.println(testData); + System.out.println(); + + // Validate that we have session metadata for complete testing + if (!candidate.hasSessionMetadata) { + System.err.println("\n⚠️ WARNING: Application has route coverage but NO SESSION METADATA"); + System.err.println(" Some tests will fail. To fix this:"); + System.err.println(" 1. Configure session metadata in your Contrast agent"); + System.err.println(" 2. Restart the application"); + System.err.println(" 3. Make some HTTP requests to exercise routes"); + System.err.println(" 4. Re-run the integration tests"); + } + } else { + String errorMsg = buildTestDataErrorMessage(appsChecked); + System.err.println(errorMsg); + fail(errorMsg); + } + + } catch (Exception e) { + String errorMsg = "❌ ERROR during test data discovery: " + e.getMessage(); + System.err.println("\n" + errorMsg); + e.printStackTrace(); + fail(errorMsg); + } + } + + /** + * Build detailed error message when no suitable test data is found + */ + private String buildTestDataErrorMessage(int appsChecked) { + StringBuilder msg = new StringBuilder(); + msg.append("\n╔════════════════════════════════════════════════════════════════════════════════╗\n"); + msg.append("║ INTEGRATION TEST SETUP FAILED - NO SUITABLE TEST DATA ║\n"); + msg.append("╚════════════════════════════════════════════════════════════════════════════════╝\n"); + msg.append("\nChecked ").append(appsChecked).append(" application(s) but none had route coverage data.\n"); + msg.append("\n📋 REQUIRED TEST DATA:\n"); + msg.append(" The integration tests require at least ONE application with:\n"); + msg.append(" ✓ Route coverage data (at least 1 discovered or exercised route)\n"); + msg.append(" ✓ Session metadata (at least 1 metadata field)\n"); + msg.append(" ✓ Multiple sessions (for latest session filtering tests)\n"); + msg.append("\n🔧 HOW TO CREATE TEST DATA:\n"); + msg.append("\n1. Deploy an application with a Contrast agent\n"); + msg.append(" Example (Java):\n"); + msg.append(" java -javaagent:/path/to/contrast.jar \\\n"); + msg.append(" -Dcontrast.api.key=... \\\n"); + msg.append(" -Dcontrast.agent.java.standalone_app_name=test-app \\\n"); + msg.append(" -jar your-app.jar\n"); + msg.append("\n2. Configure session metadata in the agent\n"); + msg.append(" Add to contrast_security.yaml or as JVM args:\n"); + msg.append(" agent:\n"); + msg.append(" session_metadata:\n"); + msg.append(" branch: main\n"); + msg.append(" build: 123\n"); + msg.append(" Or via JVM args:\n"); + msg.append(" -Dcontrast.agent.session_metadata='branch=main,build=123'\n"); + msg.append("\n3. Exercise routes by making HTTP requests\n"); + msg.append(" curl http://localhost:8080/api/users\n"); + msg.append(" curl http://localhost:8080/api/products\n"); + msg.append("\n4. Wait 30-60 seconds for agent to report data to TeamServer\n"); + msg.append("\n5. Verify data exists:\n"); + msg.append(" - Login to Contrast UI\n"); + msg.append(" - Go to application → Route Coverage tab\n"); + msg.append(" - Verify routes are listed\n"); + msg.append(" - Check session metadata is present\n"); + msg.append("\n6. Re-run integration tests:\n"); + msg.append(" source .env.integration-test && mvn verify\n"); + msg.append("\n💡 ALTERNATIVE:\n"); + msg.append(" Set TEST_APP_ID environment variable to an application ID with route coverage:\n"); + msg.append(" export TEST_APP_ID=\n"); + msg.append(" export TEST_METADATA_NAME=branch\n"); + msg.append(" export TEST_METADATA_VALUE=main\n"); + msg.append("\n"); + return msg.toString(); + } + + // ========== Test Case 1: Test Data Validation ========== + + @Test + void testDiscoveredTestDataExists() { + System.out.println("\n=== Integration Test: Validate test data discovery ==="); + + assertNotNull(testData, "Test data should have been discovered in @BeforeAll"); + assertNotNull(testData.appId, "Test application ID should be set"); + assertTrue(testData.hasRouteCoverage, "Test application should have route coverage"); + assertTrue(testData.routeCount > 0, "Test application should have at least 1 route"); + + System.out.println("✓ Test data validated:"); + System.out.println(" App ID: " + testData.appId); + System.out.println(" App Name: " + testData.appName); + System.out.println(" Route Count: " + testData.routeCount); + System.out.println(" Has Session Metadata: " + testData.hasSessionMetadata); + } + + // ========== Test Case 2: Unfiltered Query ========== + + @Test + void testGetRouteCoverage_Unfiltered_Success() throws IOException { + System.out.println("\n=== Integration Test: get_route_coverage (unfiltered) ==="); + + assertNotNull(testData, "Test data must be discovered before running tests"); + + // Act + RouteCoverageResponse response = routeCoverageService.getRouteCoverage(testData.appId, null, null, null); + + // Assert + assertNotNull(response, "Response should not be null"); + assertTrue(response.isSuccess(), "Response should indicate success"); + assertNotNull(response.getRoutes(), "Routes should not be null"); + assertTrue(response.getRoutes().size() > 0, "Should have at least 1 route"); + + System.out.println("✓ Retrieved " + response.getRoutes().size() + " routes for application: " + testData.appName); + + // Count exercised vs discovered routes + long exercisedCount = response.getRoutes().stream() + .filter(route -> route.getExercised() > 0) + .count(); + long discoveredCount = response.getRoutes().size() - exercisedCount; + + System.out.println(" Exercised routes: " + exercisedCount); + System.out.println(" Discovered routes: " + discoveredCount); + + // Verify all routes have details + for (Route route : response.getRoutes()) { + assertNotNull(route.getSignature(), "Route signature should not be null"); + assertNotNull(route.getRouteHash(), "Route hash should not be null"); + assertNotNull(route.getRouteDetailsResponse(), "Route details should be populated"); + } + } + + // ========== Test Case 3: Session Metadata Filter ========== + + @Test + void testGetRouteCoverage_SessionMetadataFilter_Success() throws IOException { + System.out.println("\n=== Integration Test: get_route_coverage (session metadata filter) ==="); + + assertNotNull(testData, "Test data must be discovered before running tests"); + assertTrue(testData.hasSessionMetadata, + "Test application must have session metadata. Found app with route coverage but no session metadata. " + + "Please configure session metadata in your Contrast agent."); + assertNotNull(testData.sessionMetadataName, "Session metadata name must be set"); + assertNotNull(testData.sessionMetadataValue, "Session metadata value must be set"); + + // Act + RouteCoverageResponse response = routeCoverageService.getRouteCoverage( + testData.appId, testData.sessionMetadataName, testData.sessionMetadataValue, null + ); + + // Assert + assertNotNull(response, "Response should not be null"); + assertTrue(response.isSuccess(), "Response should indicate success"); + assertNotNull(response.getRoutes(), "Routes should not be null"); + + System.out.println("✓ Retrieved " + response.getRoutes().size() + " routes for application: " + testData.appName); + System.out.println(" Filtered by session metadata: " + testData.sessionMetadataName + "=" + testData.sessionMetadataValue); + + // Verify route details are populated + for (Route route : response.getRoutes()) { + assertNotNull(route.getRouteDetailsResponse(), + "Route details should be populated for filtered routes"); + } + + if (response.getRoutes().size() > 0) { + System.out.println(" Sample routes:"); + response.getRoutes().stream() + .limit(3) + .forEach(route -> System.out.println(" - " + route.getSignature())); + } + } + + // ========== Test Case 4: Latest Session Filter ========== + + @Test + void testGetRouteCoverage_LatestSession_Success() throws IOException { + System.out.println("\n=== Integration Test: get_route_coverage (latest session) ==="); + + assertNotNull(testData, "Test data must be discovered before running tests"); + assertTrue(testData.hasSessionMetadata, + "Test application must have session metadata for latest session test. " + + "Please configure session metadata in your Contrast agent."); + + // Act + RouteCoverageResponse response = routeCoverageService.getRouteCoverage( + testData.appId, null, null, true + ); + + // Assert + assertNotNull(response, "Response should not be null"); + assertTrue(response.isSuccess(), + "Response should indicate success. Application should have session metadata."); + assertNotNull(response.getRoutes(), "Routes should not be null when success is true"); + + System.out.println("✓ Retrieved " + response.getRoutes().size() + " routes from latest session"); + System.out.println(" Application: " + testData.appName); + + // Count exercised vs discovered + long exercisedCount = response.getRoutes().stream() + .filter(route -> route.getExercised() > 0) + .count(); + + System.out.println(" Exercised: " + exercisedCount); + System.out.println(" Discovered: " + (response.getRoutes().size() - exercisedCount)); + + // Verify all routes have details + for (Route route : response.getRoutes()) { + assertNotNull(route.getRouteDetailsResponse(), + "Route details should be populated for latest session"); + } + } + + // ========== Comparison Test: Different Filter Types ========== + + @Test + void testGetRouteCoverage_CompareFilters() throws IOException { + System.out.println("\n=== Integration Test: Compare different filter types ==="); + + assertNotNull(testData, "Test data must be discovered before running tests"); + assertTrue(testData.hasSessionMetadata, + "Test application must have session metadata for comparison test. " + + "Please configure session metadata in your Contrast agent."); + + // Get route coverage using different filters + RouteCoverageResponse unfilteredResponse = routeCoverageService.getRouteCoverage( + testData.appId, null, null, null + ); + + RouteCoverageResponse sessionMetadataResponse = routeCoverageService.getRouteCoverage( + testData.appId, testData.sessionMetadataName, testData.sessionMetadataValue, null + ); + + RouteCoverageResponse latestSessionResponse = routeCoverageService.getRouteCoverage( + testData.appId, null, null, true + ); + + // Assert all methods returned data + assertNotNull(unfilteredResponse, "Unfiltered response should not be null"); + assertNotNull(sessionMetadataResponse, "Session metadata response should not be null"); + assertNotNull(latestSessionResponse, "Latest session response should not be null"); + + assertTrue(unfilteredResponse.isSuccess(), "Unfiltered query should succeed"); + assertTrue(sessionMetadataResponse.isSuccess(), "Session metadata query should succeed"); + assertTrue(latestSessionResponse.isSuccess(), "Latest session query should succeed"); + + System.out.println("✓ All filter types work correctly:"); + System.out.println(" Unfiltered routes: " + unfilteredResponse.getRoutes().size()); + System.out.println(" Session metadata routes: " + sessionMetadataResponse.getRoutes().size()); + System.out.println(" Latest session routes: " + latestSessionResponse.getRoutes().size()); + + // Verify unfiltered should have >= filtered results (more data when not filtered) + assertTrue(unfilteredResponse.getRoutes().size() >= sessionMetadataResponse.getRoutes().size(), + "Unfiltered query should return same or more routes than filtered query"); + + // Latest session should have routes (since we validated session metadata exists) + assertTrue(latestSessionResponse.getRoutes().size() > 0, + "Latest session query should return at least some routes"); + } + + // ========== Error Handling Test ========== + + @Test + void testGetRouteCoverage_InvalidAppId() { + System.out.println("\n=== Integration Test: Invalid app ID handling ==="); + + // Act - Use an invalid app ID that definitely doesn't exist + boolean caughtException = false; + try { + RouteCoverageResponse response = routeCoverageService.getRouteCoverage( + "invalid-app-id-12345", null, null, null + ); + + // If we get here, the API returned a response (possibly empty) + System.out.println("✓ API handled invalid app ID gracefully"); + System.out.println(" Routes returned: " + response.getRoutes().size()); + + } catch (IOException e) { + // This is also acceptable - API rejected the invalid app ID + caughtException = true; + System.out.println("✓ API rejected invalid app ID with IOException: " + e.getMessage()); + } catch (Exception e) { + // Catch other exceptions like UnauthorizedException + caughtException = true; + System.out.println("✓ API rejected invalid app ID with error: " + e.getClass().getSimpleName()); + } + + assertTrue(caughtException || true, "Either exception thrown or graceful handling - both are acceptable"); + } + + @Test + void testGetRouteCoverage_EmptyStrings_TreatedAsNull() throws Exception { + System.out.println("\n=== Integration Test: Empty string parameters (MCP-OU8 bug fix) ==="); + + // This test validates the fix for MCP-OU8: empty strings should be treated as null + // and trigger the GET endpoint (unfiltered query) instead of the POST endpoint with empty filters + + // Act - Call with empty strings for sessionMetadataName and sessionMetadataValue + RouteCoverageResponse response = routeCoverageService.getRouteCoverage( + testData.appId, "", "", false + ); + + // Assert + assertNotNull(response, "Response should not be null"); + assertTrue(response.isSuccess(), "Response should be successful"); + + System.out.println("✓ Response successful: " + response.isSuccess()); + System.out.println("✓ Routes returned: " + response.getRoutes().size()); + + // The key assertion: empty strings should NOT return "No sessions found" message + // This message indicates the POST endpoint was called incorrectly + if (response.getMessages() != null && !response.getMessages().isEmpty()) { + String combinedMessages = String.join(", ", response.getMessages()); + assertFalse( + combinedMessages.contains("No sessions found with the provided filters"), + "Empty strings should not trigger POST endpoint - messages should not contain 'No sessions found'" + ); + System.out.println("✓ Messages: " + combinedMessages); + } + + // Should return routes (assuming the app has route coverage) + if (testData.hasRouteCoverage) { + assertTrue(response.getRoutes().size() > 0, + "Empty strings should return all routes (unfiltered query) when app has route coverage"); + System.out.println("✓ Routes found via unfiltered query (empty strings treated as null)"); + + // Verify route details are populated + for (Route route : response.getRoutes()) { + assertNotNull(route.getRouteDetailsResponse(), + "Each route should have details populated"); + assertTrue(route.getRouteDetailsResponse().isSuccess(), + "Route details should be successfully loaded"); + } + System.out.println("✓ All routes have valid route details"); + } + } +} diff --git a/src/test/java/com/contrast/labs/ai/mcp/contrast/RouteCoverageServiceTest.java b/src/test/java/com/contrast/labs/ai/mcp/contrast/RouteCoverageServiceTest.java new file mode 100644 index 0000000..43d46c2 --- /dev/null +++ b/src/test/java/com/contrast/labs/ai/mcp/contrast/RouteCoverageServiceTest.java @@ -0,0 +1,543 @@ +/* + * 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; + +import com.contrast.labs.ai.mcp.contrast.sdkexstension.SDKExtension; +import com.contrast.labs.ai.mcp.contrast.sdkexstension.SDKHelper; +import com.contrast.labs.ai.mcp.contrast.sdkexstension.data.routecoverage.Route; +import com.contrast.labs.ai.mcp.contrast.sdkexstension.data.routecoverage.RouteCoverageBySessionIDAndMetadataRequestExtended; +import com.contrast.labs.ai.mcp.contrast.sdkexstension.data.routecoverage.RouteCoverageResponse; +import com.contrast.labs.ai.mcp.contrast.sdkexstension.data.routecoverage.RouteDetailsResponse; +import com.contrast.labs.ai.mcp.contrast.sdkexstension.data.sessionmetadata.AgentSession; +import com.contrast.labs.ai.mcp.contrast.sdkexstension.data.sessionmetadata.SessionMetadataResponse; +import com.contrastsecurity.models.RouteCoverageMetadataLabelValues; +import com.contrastsecurity.sdk.ContrastSDK; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Comprehensive test suite for RouteCoverageService. + * Tests the consolidated get_route_coverage method with different parameter combinations (AIML-224). + * + * The consolidated method replaces 6 previous methods: + * - Unfiltered query: getRouteCoverage(appId, null, null, null) + * - Session metadata filter: getRouteCoverage(appId, name, value, null) + * - Latest session filter: getRouteCoverage(appId, null, null, true) + */ +@ExtendWith(MockitoExtension.class) +class RouteCoverageServiceTest { + + private RouteCoverageService routeCoverageService; + private ContrastSDK mockContrastSDK; + private SDKExtension mockSDKExtension; + private MockedStatic mockedSDKHelper; + private MockedConstruction mockedSDKExtensionConstruction; + + private static final String TEST_ORG_ID = "test-org-123"; + private static final String TEST_HOST = "https://test.contrast.local"; + private static final String TEST_API_KEY = "test-api-key"; + private static final String TEST_SERVICE_KEY = "test-service-key"; + private static final String TEST_USERNAME = "test-user"; + private static final String TEST_APP_ID = "test-app-456"; + private static final String TEST_SESSION_ID = "session-789"; + private static final String TEST_METADATA_NAME = "branch"; + private static final String TEST_METADATA_VALUE = "main"; + private static final String TEST_ROUTE_HASH = "route-hash-123"; + + @BeforeEach + void setUp() throws Exception { + routeCoverageService = new RouteCoverageService(); + mockContrastSDK = mock(ContrastSDK.class); + mockSDKExtension = mock(SDKExtension.class); + + // Mock the static SDKHelper.getSDK() method + mockedSDKHelper = mockStatic(SDKHelper.class); + mockedSDKHelper.when(() -> SDKHelper.getSDK( + anyString(), anyString(), anyString(), anyString(), anyString(), anyString() + )).thenReturn(mockContrastSDK); + + // Mock SDKExtension construction to return our mock + mockedSDKExtensionConstruction = mockConstruction(SDKExtension.class, + (mock, context) -> { + // Configure the mock to behave like mockSDKExtension + when(mock.getRouteCoverage(anyString(), anyString(), any())) + .thenAnswer(invocation -> mockSDKExtension.getRouteCoverage( + invocation.getArgument(0), + invocation.getArgument(1), + invocation.getArgument(2) + )); + when(mock.getRouteDetails(anyString(), anyString(), anyString())) + .thenAnswer(invocation -> mockSDKExtension.getRouteDetails( + invocation.getArgument(0), + invocation.getArgument(1), + invocation.getArgument(2) + )); + when(mock.getLatestSessionMetadata(anyString(), anyString())) + .thenAnswer(invocation -> mockSDKExtension.getLatestSessionMetadata( + invocation.getArgument(0), + invocation.getArgument(1) + )); + } + ); + + // Set required configuration fields using reflection + ReflectionTestUtils.setField(routeCoverageService, "orgID", TEST_ORG_ID); + ReflectionTestUtils.setField(routeCoverageService, "hostName", TEST_HOST); + ReflectionTestUtils.setField(routeCoverageService, "apiKey", TEST_API_KEY); + ReflectionTestUtils.setField(routeCoverageService, "serviceKey", TEST_SERVICE_KEY); + ReflectionTestUtils.setField(routeCoverageService, "userName", TEST_USERNAME); + ReflectionTestUtils.setField(routeCoverageService, "httpProxyHost", ""); + ReflectionTestUtils.setField(routeCoverageService, "httpProxyPort", ""); + } + + @AfterEach + void tearDown() { + if (mockedSDKHelper != null) { + mockedSDKHelper.close(); + } + if (mockedSDKExtensionConstruction != null) { + mockedSDKExtensionConstruction.close(); + } + } + + // ========== Helper Methods ========== + + private RouteCoverageResponse createMockRouteCoverageResponse(int routeCount) { + RouteCoverageResponse response = new RouteCoverageResponse(); + response.setSuccess(true); + List routes = new ArrayList<>(); + + for (int i = 0; i < routeCount; i++) { + Route route = new Route(); + route.setSignature("GET /api/endpoint" + i); + route.setRouteHash(TEST_ROUTE_HASH + "-" + i); + route.setExercised(i % 2 == 0 ? 1L : 0L); // Alternate between exercised (>0) and discovered (0) + routes.add(route); + } + + response.setRoutes(routes); + return response; + } + + private RouteDetailsResponse createMockRouteDetailsResponse() { + RouteDetailsResponse response = new RouteDetailsResponse(); + response.setSuccess(true); + return response; + } + + private SessionMetadataResponse createMockSessionMetadataResponse() { + SessionMetadataResponse response = new SessionMetadataResponse(); + AgentSession session = new AgentSession(); + session.setAgentSessionId(TEST_SESSION_ID); + response.setAgentSession(session); + return response; + } + + // ========== Test Case 1: Unfiltered Query (all parameters null) ========== + + @Test + void testGetRouteCoverage_UnfilteredQuery_Success() throws Exception { + // Arrange + RouteCoverageResponse mockResponse = createMockRouteCoverageResponse(3); + when(mockSDKExtension.getRouteCoverage(eq(TEST_ORG_ID), eq(TEST_APP_ID), isNull())) + .thenReturn(mockResponse); + + when(mockSDKExtension.getRouteDetails(eq(TEST_ORG_ID), eq(TEST_APP_ID), anyString())) + .thenReturn(createMockRouteDetailsResponse()); + + // Act + RouteCoverageResponse result = routeCoverageService.getRouteCoverage(TEST_APP_ID, null, null, null); + + // Assert + assertNotNull(result); + assertTrue(result.isSuccess()); + assertEquals(3, result.getRoutes().size()); + + // Verify SDK was called with null metadata (unfiltered query) + verify(mockSDKExtension).getRouteCoverage(eq(TEST_ORG_ID), eq(TEST_APP_ID), isNull()); + verify(mockSDKExtension, times(3)).getRouteDetails(eq(TEST_ORG_ID), eq(TEST_APP_ID), anyString()); + } + + @Test + void testGetRouteCoverage_UnfilteredQuery_EmptyRoutes() throws Exception { + // Arrange + RouteCoverageResponse mockResponse = createMockRouteCoverageResponse(0); + when(mockSDKExtension.getRouteCoverage(eq(TEST_ORG_ID), eq(TEST_APP_ID), isNull())) + .thenReturn(mockResponse); + + // Act + RouteCoverageResponse result = routeCoverageService.getRouteCoverage(TEST_APP_ID, null, null, null); + + // Assert + assertNotNull(result); + assertTrue(result.isSuccess()); + assertEquals(0, result.getRoutes().size()); + + // Verify no route details calls made when no routes + verify(mockSDKExtension, never()).getRouteDetails(anyString(), anyString(), anyString()); + } + + // ========== Test Case 2: Session Metadata Filter ========== + + @Test + void testGetRouteCoverage_SessionMetadataFilter_Success() throws Exception { + // Arrange + RouteCoverageResponse mockResponse = createMockRouteCoverageResponse(4); + when(mockSDKExtension.getRouteCoverage(eq(TEST_ORG_ID), eq(TEST_APP_ID), any(RouteCoverageBySessionIDAndMetadataRequestExtended.class))) + .thenReturn(mockResponse); + + when(mockSDKExtension.getRouteDetails(eq(TEST_ORG_ID), eq(TEST_APP_ID), anyString())) + .thenReturn(createMockRouteDetailsResponse()); + + // Act + RouteCoverageResponse result = routeCoverageService.getRouteCoverage( + TEST_APP_ID, TEST_METADATA_NAME, TEST_METADATA_VALUE, null + ); + + // Assert + assertNotNull(result); + assertTrue(result.isSuccess()); + assertEquals(4, result.getRoutes().size()); + + // Verify metadata filter structure + ArgumentCaptor captor = + ArgumentCaptor.forClass(RouteCoverageBySessionIDAndMetadataRequestExtended.class); + verify(mockSDKExtension).getRouteCoverage(eq(TEST_ORG_ID), eq(TEST_APP_ID), captor.capture()); + + RouteCoverageBySessionIDAndMetadataRequestExtended request = captor.getValue(); + assertNotNull(request); + assertNotNull(request.getValues()); + assertEquals(1, request.getValues().size()); + + RouteCoverageMetadataLabelValues metadata = request.getValues().get(0); + assertEquals(TEST_METADATA_NAME, metadata.getLabel()); + assertEquals(1, metadata.getValues().size()); + assertEquals(TEST_METADATA_VALUE, metadata.getValues().get(0)); + + // Verify route details fetched for each route + verify(mockSDKExtension, times(4)).getRouteDetails(eq(TEST_ORG_ID), eq(TEST_APP_ID), anyString()); + } + + @Test + void testGetRouteCoverage_SessionMetadataFilter_MultipleRoutes() throws Exception { + // Arrange + RouteCoverageResponse mockResponse = createMockRouteCoverageResponse(5); + when(mockSDKExtension.getRouteCoverage(eq(TEST_ORG_ID), eq(TEST_APP_ID), any())) + .thenReturn(mockResponse); + + when(mockSDKExtension.getRouteDetails(eq(TEST_ORG_ID), eq(TEST_APP_ID), anyString())) + .thenReturn(createMockRouteDetailsResponse()); + + // Act + RouteCoverageResponse result = routeCoverageService.getRouteCoverage( + TEST_APP_ID, TEST_METADATA_NAME, TEST_METADATA_VALUE, null + ); + + // Assert + assertEquals(5, result.getRoutes().size()); + + // Verify route details fetched for each route + verify(mockSDKExtension, times(5)).getRouteDetails(eq(TEST_ORG_ID), eq(TEST_APP_ID), anyString()); + + // Verify each route has details attached + for (Route route : result.getRoutes()) { + assertNotNull(route.getRouteDetailsResponse()); + } + } + + @Test + void testGetRouteCoverage_SessionMetadataFilter_MissingValue() throws Exception { + // Act & Assert + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + routeCoverageService.getRouteCoverage(TEST_APP_ID, TEST_METADATA_NAME, null, null); + }); + + assertTrue(exception.getMessage().contains("sessionMetadataValue is required")); + + // Verify SDK was never called + verify(mockSDKExtension, never()).getRouteCoverage(anyString(), anyString(), any()); + } + + @Test + void testGetRouteCoverage_SessionMetadataFilter_EmptyValue() throws Exception { + // Test validation with empty string for sessionMetadataValue (MCP-3EG) + // Act & Assert + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + routeCoverageService.getRouteCoverage(TEST_APP_ID, TEST_METADATA_NAME, "", null); + }); + + assertTrue(exception.getMessage().contains("sessionMetadataValue is required")); + + // Verify SDK was never called + verify(mockSDKExtension, never()).getRouteCoverage(anyString(), anyString(), any()); + } + + // ========== Test Case 3: Latest Session Filter ========== + + @Test + void testGetRouteCoverage_LatestSessionFilter_Success() throws Exception { + // Arrange + SessionMetadataResponse sessionResponse = createMockSessionMetadataResponse(); + when(mockSDKExtension.getLatestSessionMetadata(eq(TEST_ORG_ID), eq(TEST_APP_ID))) + .thenReturn(sessionResponse); + + RouteCoverageResponse mockResponse = createMockRouteCoverageResponse(2); + when(mockSDKExtension.getRouteCoverage(eq(TEST_ORG_ID), eq(TEST_APP_ID), any(RouteCoverageBySessionIDAndMetadataRequestExtended.class))) + .thenReturn(mockResponse); + + when(mockSDKExtension.getRouteDetails(eq(TEST_ORG_ID), eq(TEST_APP_ID), anyString())) + .thenReturn(createMockRouteDetailsResponse()); + + // Act + RouteCoverageResponse result = routeCoverageService.getRouteCoverage( + TEST_APP_ID, null, null, true + ); + + // Assert + assertNotNull(result); + assertTrue(result.isSuccess()); + assertEquals(2, result.getRoutes().size()); + + // Verify latest session was fetched + verify(mockSDKExtension).getLatestSessionMetadata(eq(TEST_ORG_ID), eq(TEST_APP_ID)); + + // Verify session ID was used in request + ArgumentCaptor captor = + ArgumentCaptor.forClass(RouteCoverageBySessionIDAndMetadataRequestExtended.class); + verify(mockSDKExtension).getRouteCoverage(eq(TEST_ORG_ID), eq(TEST_APP_ID), captor.capture()); + + RouteCoverageBySessionIDAndMetadataRequestExtended request = captor.getValue(); + assertNotNull(request); + // Note: Can't verify sessionId directly as it's protected in base class + // But we can verify the method was called + } + + @Test + void testGetRouteCoverage_LatestSessionFilter_NoSessionMetadata() throws Exception { + // Arrange - Return null session metadata + when(mockSDKExtension.getLatestSessionMetadata(eq(TEST_ORG_ID), eq(TEST_APP_ID))) + .thenReturn(null); + + // Act + RouteCoverageResponse result = routeCoverageService.getRouteCoverage( + TEST_APP_ID, null, null, true + ); + + // Assert - Should return empty response with success=false + assertNotNull(result); + assertFalse(result.isSuccess()); + + // Verify route coverage was NOT called + verify(mockSDKExtension, never()).getRouteCoverage(anyString(), anyString(), any()); + } + + @Test + void testGetRouteCoverage_LatestSessionFilter_NullAgentSession() throws Exception { + // Arrange - Return session metadata with null agent session + SessionMetadataResponse sessionResponse = new SessionMetadataResponse(); + sessionResponse.setAgentSession(null); + when(mockSDKExtension.getLatestSessionMetadata(eq(TEST_ORG_ID), eq(TEST_APP_ID))) + .thenReturn(sessionResponse); + + // Act + RouteCoverageResponse result = routeCoverageService.getRouteCoverage( + TEST_APP_ID, null, null, true + ); + + // Assert - Should return empty response with success=false + assertNotNull(result); + assertFalse(result.isSuccess()); + + // Verify route coverage was NOT called + verify(mockSDKExtension, never()).getRouteCoverage(anyString(), anyString(), any()); + } + + // ========== Error Handling Tests ========== + + @Test + void testGetRouteCoverage_SDKThrowsIOException() throws Exception { + // Arrange + when(mockSDKExtension.getRouteCoverage(eq(TEST_ORG_ID), eq(TEST_APP_ID), isNull())) + .thenThrow(new IOException("API connection failed")); + + // Act & Assert + IOException exception = assertThrows(IOException.class, () -> { + routeCoverageService.getRouteCoverage(TEST_APP_ID, null, null, null); + }); + + assertEquals("API connection failed", exception.getMessage()); + } + + @Test + void testGetRouteCoverage_RouteDetailsFails() throws Exception { + // Arrange + RouteCoverageResponse mockResponse = createMockRouteCoverageResponse(2); + when(mockSDKExtension.getRouteCoverage(eq(TEST_ORG_ID), eq(TEST_APP_ID), any())) + .thenReturn(mockResponse); + + // First route succeeds, second route fails + when(mockSDKExtension.getRouteDetails(eq(TEST_ORG_ID), eq(TEST_APP_ID), anyString())) + .thenReturn(createMockRouteDetailsResponse()) + .thenThrow(new IOException("Failed to fetch route details")); + + // Act & Assert + IOException exception = assertThrows(IOException.class, () -> { + routeCoverageService.getRouteCoverage(TEST_APP_ID, TEST_METADATA_NAME, TEST_METADATA_VALUE, null); + }); + + assertTrue(exception.getMessage().contains("Failed to fetch route details")); + } + + @Test + void testGetRouteCoverage_LatestSessionFetchFails() throws Exception { + // Arrange + when(mockSDKExtension.getLatestSessionMetadata(eq(TEST_ORG_ID), eq(TEST_APP_ID))) + .thenThrow(new IOException("Session metadata API failed")); + + // Act & Assert + IOException exception = assertThrows(IOException.class, () -> { + routeCoverageService.getRouteCoverage(TEST_APP_ID, null, null, true); + }); + + assertTrue(exception.getMessage().contains("Session metadata API failed")); + } + + // ========== SDK Configuration Tests ========== + + @Test + void testGetRouteCoverage_UsesCorrectSDKConfiguration() throws Exception { + // Arrange + when(mockSDKExtension.getRouteCoverage(anyString(), anyString(), any())) + .thenReturn(createMockRouteCoverageResponse(0)); + + // Act + routeCoverageService.getRouteCoverage(TEST_APP_ID, null, null, null); + + // Assert - Verify SDKHelper was called with correct configuration + mockedSDKHelper.verify(() -> SDKHelper.getSDK( + eq(TEST_HOST), + eq(TEST_API_KEY), + eq(TEST_SERVICE_KEY), + eq(TEST_USERNAME), + eq(""), // httpProxyHost + eq("") // httpProxyPort + )); + } + + // ========== Parameter Combination Tests ========== + + @Test + void testGetRouteCoverage_AllParametersNull() throws Exception { + // Arrange + RouteCoverageResponse mockResponse = createMockRouteCoverageResponse(1); + when(mockSDKExtension.getRouteCoverage(eq(TEST_ORG_ID), eq(TEST_APP_ID), isNull())) + .thenReturn(mockResponse); + when(mockSDKExtension.getRouteDetails(any(), any(), any())) + .thenReturn(createMockRouteDetailsResponse()); + + // Act + RouteCoverageResponse result = routeCoverageService.getRouteCoverage(TEST_APP_ID, null, null, null); + + // Assert + assertNotNull(result); + assertTrue(result.isSuccess()); + verify(mockSDKExtension).getRouteCoverage(eq(TEST_ORG_ID), eq(TEST_APP_ID), isNull()); + } + + @Test + void testGetRouteCoverage_UseLatestSessionFalse() throws Exception { + // Arrange - useLatestSession=false should behave same as null (no filter) + RouteCoverageResponse mockResponse = createMockRouteCoverageResponse(1); + when(mockSDKExtension.getRouteCoverage(eq(TEST_ORG_ID), eq(TEST_APP_ID), isNull())) + .thenReturn(mockResponse); + when(mockSDKExtension.getRouteDetails(any(), any(), any())) + .thenReturn(createMockRouteDetailsResponse()); + + // Act + RouteCoverageResponse result = routeCoverageService.getRouteCoverage(TEST_APP_ID, null, null, false); + + // Assert + assertNotNull(result); + assertTrue(result.isSuccess()); + verify(mockSDKExtension).getRouteCoverage(eq(TEST_ORG_ID), eq(TEST_APP_ID), isNull()); + verify(mockSDKExtension, never()).getLatestSessionMetadata(anyString(), anyString()); + } + + @Test + void testGetRouteCoverage_EmptyStringParameters_TreatedAsNull() throws Exception { + // Arrange - Empty strings should be treated as null and trigger GET endpoint + // This fixes bug MCP-OU8 where empty strings were incorrectly treated as valid filters + RouteCoverageResponse mockResponse = createMockRouteCoverageResponse(2); + when(mockSDKExtension.getRouteCoverage(eq(TEST_ORG_ID), eq(TEST_APP_ID), isNull())) + .thenReturn(mockResponse); + when(mockSDKExtension.getRouteDetails(any(), any(), any())) + .thenReturn(createMockRouteDetailsResponse()); + + // Act - Pass empty strings for sessionMetadataName and sessionMetadataValue + RouteCoverageResponse result = routeCoverageService.getRouteCoverage(TEST_APP_ID, "", "", false); + + // Assert - Should call SDK with null (unfiltered query), not with empty metadata filter + assertNotNull(result); + assertTrue(result.isSuccess()); + assertEquals(2, result.getRoutes().size()); + + // Verify SDK was called with null metadata (unfiltered query) - GET endpoint + verify(mockSDKExtension).getRouteCoverage(eq(TEST_ORG_ID), eq(TEST_APP_ID), isNull()); + + // Verify it did NOT try to create a metadata filter request with empty strings + verify(mockSDKExtension, never()).getRouteCoverage( + eq(TEST_ORG_ID), + eq(TEST_APP_ID), + any(RouteCoverageBySessionIDAndMetadataRequestExtended.class) + ); + + // Verify route details were fetched + verify(mockSDKExtension, times(2)).getRouteDetails(eq(TEST_ORG_ID), eq(TEST_APP_ID), anyString()); + } + + @Test + void testGetRouteCoverage_EmptySessionMetadataNameOnly_TreatedAsNull() throws Exception { + // Arrange - Empty sessionMetadataName with null value should also trigger unfiltered query + RouteCoverageResponse mockResponse = createMockRouteCoverageResponse(1); + when(mockSDKExtension.getRouteCoverage(eq(TEST_ORG_ID), eq(TEST_APP_ID), isNull())) + .thenReturn(mockResponse); + when(mockSDKExtension.getRouteDetails(any(), any(), any())) + .thenReturn(createMockRouteDetailsResponse()); + + // Act + RouteCoverageResponse result = routeCoverageService.getRouteCoverage(TEST_APP_ID, "", null, null); + + // Assert + assertNotNull(result); + assertTrue(result.isSuccess()); + verify(mockSDKExtension).getRouteCoverage(eq(TEST_ORG_ID), eq(TEST_APP_ID), isNull()); + } +}