diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/exception/AviatorConnectivityException.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/exception/AviatorConnectivityException.java new file mode 100644 index 00000000000..6df5f9db285 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/exception/AviatorConnectivityException.java @@ -0,0 +1,27 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.exception; + +/** + * Aviator connection failure with a message that is safe to show. + *

+ * The transport cause is not attached: its text can contain proxy credentials or + * host details, and fcli prints and logs the cause chain of simple exceptions. + */ +public final class AviatorConnectivityException extends AviatorSimpleException { + private static final long serialVersionUID = 1L; + + public AviatorConnectivityException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorConnectionContext.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorConnectionContext.java new file mode 100644 index 00000000000..e2746ef4d64 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorConnectionContext.java @@ -0,0 +1,44 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import java.util.Objects; + +import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper.AviatorConnectionPlan; + +import io.grpc.CallOptions; + +/** + * Endpoints retained for error messages, without proxy credentials. + * @param proxy null for a direct connection + */ +record AviatorConnectionContext(AviatorEndpoint target, AviatorEndpoint proxy) { + // Present only on fail-fast unary stubs; absence preserves the existing error mapping. + static final CallOptions.Key CALL_OPTION = CallOptions.Key.create("aviator-connectivity"); + + AviatorConnectionContext { + Objects.requireNonNull(target, "target"); + } + + static AviatorConnectionContext of(AviatorConnectionPlan plan) { + var target = new AviatorEndpoint(plan.target().host(), plan.effectivePort()); + var proxy = plan.proxyDescriptor() + .map(d -> new AviatorEndpoint(d.getProxyHost(), d.getProxyPort())) + .orElse(null); + return new AviatorConnectionContext(target, proxy); + } + + boolean isProxied() { + return proxy!=null; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorConnectivityFailureKind.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorConnectivityFailureKind.java new file mode 100644 index 00000000000..05b6776be16 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorConnectivityFailureKind.java @@ -0,0 +1,125 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import java.net.ConnectException; +import java.net.UnknownHostException; +import java.nio.channels.UnresolvedAddressException; +import java.security.cert.CertPathValidatorException; +import java.security.cert.CertificateException; +import java.util.Optional; + +import javax.net.ssl.SSLException; + +import org.apache.commons.lang3.exception.ExceptionUtils; + +import com.fortify.cli.aviator.util.Constants; + +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import io.grpc.netty.shaded.io.netty.channel.ConnectTimeoutException; +import io.grpc.netty.shaded.io.netty.handler.proxy.HttpProxyHandler.HttpProxyConnectException; +import io.grpc.netty.shaded.io.netty.handler.proxy.ProxyConnectException; + +/** Shared classification and messages for ordinary unary request failures. */ +enum AviatorConnectivityFailureKind { + PROXY_REJECTED, + PROXY_NEGOTIATION_FAILED, + CONNECT_TIMEOUT, + PROXY_REFUSED, + DIRECT_REFUSED, + DNS_FAILURE, + TLS_FAILURE, + UNAVAILABLE, + DEADLINE_EXCEEDED; + + /** + * Leaves certificate failures to the fcli PKIX trust-store guidance, and application + * statuses to the existing exception mapping. + */ + static Optional classify(StatusRuntimeException e, AviatorConnectionContext context) { + var code = e.getStatus().getCode(); + if ( code!=Status.Code.UNAVAILABLE && code!=Status.Code.DEADLINE_EXCEEDED ) { + return Optional.empty(); + } + if ( isCertificateFailure(e) ) { + return Optional.empty(); + } + if ( has(e, SSLException.class) ) { + return Optional.of(TLS_FAILURE); + } + return Optional.of(classifyTransport(e, context, code)); + } + + // JDK PKIX and host name failures always carry one of these causes; other + // handshake failures, such as timeouts or protocol mismatches, are TLS_FAILURE. + private static boolean isCertificateFailure(Throwable e) { + return has(e, CertificateException.class) || has(e, CertPathValidatorException.class); + } + + // The order is significant: all Netty connect and proxy exceptions extend ConnectException. + private static AviatorConnectivityFailureKind classifyTransport(Throwable e, AviatorConnectionContext context, Status.Code code) { + var neutral = code==Status.Code.DEADLINE_EXCEEDED ? DEADLINE_EXCEEDED : UNAVAILABLE; + if ( has(e, ProxyConnectException.class) ) { + // A JVM or GRPC_PROXY_EXP proxy can be active without an fcli proxy; fcli cannot name it. + if ( !context.isProxied() ) { return neutral; } + return has(e, HttpProxyConnectException.class) ? PROXY_REJECTED : PROXY_NEGOTIATION_FAILED; + } + if ( has(e, ConnectTimeoutException.class) ) { return CONNECT_TIMEOUT; } + // With an HTTP CONNECT proxy, the proxy is the only TCP peer of the client; + // a target refusal arrives as HttpProxyConnectException instead. + if ( isConnectionRefused(e) ) { return context.isProxied() ? PROXY_REFUSED : DIRECT_REFUSED; } + if ( has(e, UnknownHostException.class) || has(e, UnresolvedAddressException.class) ) { return DNS_FAILURE; } + return neutral; + } + + // Java has no refusal-specific exception type. Match complete known reasons, never + // substrings that could come from a host name; unknown/localized reasons stay neutral. + private static boolean isConnectionRefused(Throwable e) { + return ExceptionUtils.getThrowableList(e).stream() + .filter(ConnectException.class::isInstance) + .map(Throwable::getMessage) + .anyMatch(message -> "Connection refused".equalsIgnoreCase(message) + || "Connection refused: getsockopt".equalsIgnoreCase(message) + || "Connection refused: no further information".equalsIgnoreCase(message) + || "connect(..) failed: Connection refused".equalsIgnoreCase(message) + || "finishConnect(..) failed: Connection refused".equalsIgnoreCase(message)); + } + + private static boolean has(Throwable e, Class type) { + return ExceptionUtils.indexOfType(e, type) >= 0; + } + + String formatMessage(AviatorConnectionContext context) { + var server = "Aviator server " + context.target().display(); + var proxy = context.isProxied() ? "proxy " + context.proxy().display() : null; + return switch (this) { + case PROXY_REJECTED -> "Proxy " + context.proxy().display() + " rejected the connection to " + server + + "; check the proxy credentials and access rules"; + case PROXY_NEGOTIATION_FAILED -> "Could not connect to " + server + " through " + proxy + + "; check that the proxy allows HTTPS connections to the Aviator server"; + case CONNECT_TIMEOUT -> "Timed out connecting to " + (proxy==null ? server : proxy) + + "; check network access and firewall rules"; + case PROXY_REFUSED -> "Proxy " + context.proxy().display() + " refused the connection; check the proxy host and port"; + case DIRECT_REFUSED -> server + " refused the connection; check the Aviator URL"; + case DNS_FAILURE -> proxy==null + ? "Could not resolve the host name of " + server + "; check the Aviator URL" + : "Could not resolve the host name of " + proxy + " or " + server + "; check the proxy settings and the Aviator URL"; + case TLS_FAILURE -> "Could not set up a TLS connection to " + server + + "; check that the Aviator URL uses the HTTPS port of the Aviator server"; + case UNAVAILABLE -> "Cannot reach " + server + "; check the Aviator URL, network access, and proxy settings"; + case DEADLINE_EXCEEDED -> server + " did not respond within " + Constants.DEFAULT_TIMEOUT_SECONDS + + " seconds; check network access and proxy settings, or try again later"; + }; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorEndpoint.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorEndpoint.java new file mode 100644 index 00000000000..234500c611e --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorEndpoint.java @@ -0,0 +1,36 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +/** Replaces control characters in host names before display. */ +record AviatorEndpoint(String host, int port) { + private static final String UNKNOWN_HOST = "unknown"; + + AviatorEndpoint { + host = sanitize(host); + } + + String display() { + var isUnbracketedIpv6 = host.contains(":") && !host.startsWith("["); + return isUnbracketedIpv6 ? "[" + host + "]:" + port : host + ":" + port; + } + + private static String sanitize(String host) { + if ( host==null || host.isBlank() ) { + return UNKNOWN_HOST; + } + var result = new StringBuilder(host.length()); + host.codePoints().forEach(c -> result.appendCodePoint(Character.isISOControl(c) ? '?' : c)); + return result.toString(); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClient.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClient.java index 9903b9a72b6..6e99f9b1f67 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClient.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClient.java @@ -14,6 +14,7 @@ import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Queue; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; @@ -95,14 +96,29 @@ public class AviatorGrpcClient implements AutoCloseable { final AtomicBoolean isShutdown = new AtomicBoolean(false); public AviatorGrpcClient(ManagedChannel channel, long defaultTimeoutSeconds, IAviatorLogger logger, long pingIntervalSeconds) { + this(channel, defaultTimeoutSeconds, logger, pingIntervalSeconds, AviatorRequestPolicy.WAIT_FOR_READY, null); + } + + /** Fail-fast clients require a connection context for error messages. */ + AviatorGrpcClient(ManagedChannel channel, long defaultTimeoutSeconds, IAviatorLogger logger, long pingIntervalSeconds, + AviatorRequestPolicy requestPolicy, AviatorConnectionContext connectionContext) { LOG.debug("Initializing AviatorGrpcClient with ManagedChannel"); + Objects.requireNonNull(requestPolicy, "requestPolicy"); this.logger = logger; this.channel = channel; this.asyncStub = AuditorServiceGrpc.newStub(channel).withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE).withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE).withWaitForReady(); - this.blockingStub = ApplicationServiceGrpc.newBlockingStub(channel).withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE).withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE).withWaitForReady(); - this.tokenServiceBlockingStub = TokenServiceGrpc.newBlockingStub(channel).withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE).withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE).withWaitForReady(); - this.entitlementServiceBlockingStub = EntitlementServiceGrpc.newBlockingStub(channel).withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE).withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE).withWaitForReady(); - this.dastEntitlementServiceBlockingStub = DastEntitlementServiceGrpc.newBlockingStub(channel).withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE).withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE).withWaitForReady(); + this.blockingStub = requestPolicy.configureNewStub(ApplicationServiceGrpc.newBlockingStub(channel) + .withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE) + .withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE), connectionContext); + this.tokenServiceBlockingStub = requestPolicy.configureNewStub(TokenServiceGrpc.newBlockingStub(channel) + .withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE) + .withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE), connectionContext); + this.entitlementServiceBlockingStub = requestPolicy.configureNewStub(EntitlementServiceGrpc.newBlockingStub(channel) + .withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE) + .withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE), connectionContext); + this.dastEntitlementServiceBlockingStub = requestPolicy.configureNewStub(DastEntitlementServiceGrpc.newBlockingStub(channel) + .withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE) + .withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE), connectionContext); this.correlationAsyncStub = CorrelationServiceGrpc.newStub(channel).withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE).withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE).withWaitForReady(); this.dastAuditAsyncStub = DastAuditServiceGrpc.newStub(channel) .withCompression("gzip") @@ -189,27 +205,32 @@ public void close() { public Application createApplication(String name, String tenantName, String signature, String message) { CreateApplicationRequest request = CreateApplicationRequest.newBuilder().setName(name).setTenantName(tenantName).setSignature(signature).setMessage(message).build(); - return GrpcUtil.executeGrpcCall(blockingStub, ApplicationServiceGrpc.ApplicationServiceBlockingStub::createApplication, request, Constants.OP_CREATE_APP); + return GrpcUtil.executeGrpcCall(blockingStub, + ApplicationServiceGrpc.ApplicationServiceBlockingStub::createApplication, request, Constants.OP_CREATE_APP); } public Application updateApplication(String projectId, String newName, String signature, String message, String tenantName) { UpdateApplicationRequest request = UpdateApplicationRequest.newBuilder().setId(Long.parseLong(projectId)).setName(newName).setTenantName(tenantName).setSignature(signature).setMessage(message).build(); - return GrpcUtil.executeGrpcCall(blockingStub, ApplicationServiceGrpc.ApplicationServiceBlockingStub::updateApplication, request, Constants.OP_UPDATE_APP); + return GrpcUtil.executeGrpcCall(blockingStub, + ApplicationServiceGrpc.ApplicationServiceBlockingStub::updateApplication, request, Constants.OP_UPDATE_APP); } public Application addEntitlement(String projectId, String signature, String message, String tenantName) { ApplicationById request = ApplicationById.newBuilder().setId(Long.parseLong(projectId)).setSignature(signature).setMessage(message).setTenantName(tenantName).build(); - return GrpcUtil.executeGrpcCall(blockingStub, ApplicationServiceGrpc.ApplicationServiceBlockingStub::addEntitlement, request, Constants.OP_ADD_APP_ENTITLEMENT); + return GrpcUtil.executeGrpcCall(blockingStub, + ApplicationServiceGrpc.ApplicationServiceBlockingStub::addEntitlement, request, Constants.OP_ADD_APP_ENTITLEMENT); } public ApplicationResponseMessage deleteApplication(String projectId, String signature, String message, String tenantName) { ApplicationById request = ApplicationById.newBuilder().setId(Long.parseLong(projectId)).setSignature(signature).setMessage(message).setTenantName(tenantName).build(); - return GrpcUtil.executeGrpcCall(blockingStub, ApplicationServiceGrpc.ApplicationServiceBlockingStub::deleteApplication, request, Constants.OP_DELETE_APP); + return GrpcUtil.executeGrpcCall(blockingStub, + ApplicationServiceGrpc.ApplicationServiceBlockingStub::deleteApplication, request, Constants.OP_DELETE_APP); } public Application getApplication(String projectId, String signature, String message, String tenantName) { ApplicationById request = ApplicationById.newBuilder().setId(Long.parseLong(projectId)).setSignature(signature).setMessage(message).setTenantName(tenantName).build(); - return GrpcUtil.executeGrpcCall(blockingStub, ApplicationServiceGrpc.ApplicationServiceBlockingStub::getApplication, request, Constants.OP_GET_APP); + return GrpcUtil.executeGrpcCall(blockingStub, + ApplicationServiceGrpc.ApplicationServiceBlockingStub::getApplication, request, Constants.OP_GET_APP); } public Application getApplicationByToken(String token, String appName) { @@ -217,7 +238,8 @@ public Application getApplicationByToken(String token, String appName) { .setToken(token) .setAppName(appName) .build(); - return GrpcUtil.executeGrpcCall(blockingStub, ApplicationServiceGrpc.ApplicationServiceBlockingStub::getApplicationByToken, request, Constants.OP_GET_APP_BY_TOKEN); + return GrpcUtil.executeGrpcCall(blockingStub, + ApplicationServiceGrpc.ApplicationServiceBlockingStub::getApplicationByToken, request, Constants.OP_GET_APP_BY_TOKEN); } public long getDefaultQuota(String token) { @@ -250,18 +272,21 @@ public void validateAdminSession(String tenantName, String signature, String mes public List listApplication(String tenantName, String signature, String message) { ApplicationByTenantName request = ApplicationByTenantName.newBuilder().setName(tenantName).setSignature(signature).setMessage(message).build(); - ApplicationList applicationList = GrpcUtil.executeGrpcCall(blockingStub, ApplicationServiceGrpc.ApplicationServiceBlockingStub::listApplications, request, Constants.OP_LIST_APPS); + ApplicationList applicationList = GrpcUtil.executeGrpcCall(blockingStub, + ApplicationServiceGrpc.ApplicationServiceBlockingStub::listApplications, request, Constants.OP_LIST_APPS); return applicationList.getApplicationsList(); } public TokenGenerationResponse generateToken(String email, String tokenName, String signature, String message, String tenantName, String endDate) { TokenGenerationRequest request = TokenGenerationRequest.newBuilder().setEmail(email != null ? email : "").setCustomTokenName(tokenName != null ? tokenName : "").setRequestSignature(signature).setEndDate(com.fortify.cli.aviator.util.StringUtil.isEmpty(endDate) ? "" : endDate).setMessage(message).setTenantName(tenantName).build(); - return GrpcUtil.executeGrpcCall(tokenServiceBlockingStub, TokenServiceGrpc.TokenServiceBlockingStub::generateToken, request, Constants.OP_GENERATE_TOKEN); + return GrpcUtil.executeGrpcCall(tokenServiceBlockingStub, + TokenServiceGrpc.TokenServiceBlockingStub::generateToken, request, Constants.OP_GENERATE_TOKEN); } public ListTokensResponse listTokens(String email, String tenantName, String signature, String message) { ListTokensRequest request = ListTokensRequest.newBuilder().setRequestSignature(signature).setMessage(message).setTenantName(tenantName).setIgnorePagination(true).build(); - return GrpcUtil.executeGrpcCall(tokenServiceBlockingStub, TokenServiceGrpc.TokenServiceBlockingStub::listTokens, request, Constants.OP_LIST_TOKENS); + return GrpcUtil.executeGrpcCall(tokenServiceBlockingStub, + TokenServiceGrpc.TokenServiceBlockingStub::listTokens, request, Constants.OP_LIST_TOKENS); } public ListTokensResponse listTokensByDeveloper(String tenantName, String developerEmail, String signature, String message) { @@ -270,38 +295,46 @@ public ListTokensResponse listTokensByDeveloper(String tenantName, String develo requestBuilder.setDeveloperEmail(developerEmail); } ListTokensByDeveloperRequest request = requestBuilder.build(); - return GrpcUtil.executeGrpcCall(tokenServiceBlockingStub, TokenServiceGrpc.TokenServiceBlockingStub::listTokensByDeveloper, request, Constants.OP_LIST_TOKENS_BY_DEVELOPER); + return GrpcUtil.executeGrpcCall(tokenServiceBlockingStub, + TokenServiceGrpc.TokenServiceBlockingStub::listTokensByDeveloper, request, Constants.OP_LIST_TOKENS_BY_DEVELOPER); } public RevokeTokenResponse revokeToken(String token, String email, String tenantName, String signature, String message) { RevokeTokenRequest request = RevokeTokenRequest.newBuilder().setToken(token).setTenantName(tenantName).setRequestSignature(signature).setMessage(message).build(); - return GrpcUtil.executeGrpcCall(tokenServiceBlockingStub, TokenServiceGrpc.TokenServiceBlockingStub::revokeToken, request, Constants.OP_REVOKE_TOKEN); + return GrpcUtil.executeGrpcCall(tokenServiceBlockingStub, + TokenServiceGrpc.TokenServiceBlockingStub::revokeToken, request, Constants.OP_REVOKE_TOKEN); } public DeleteTokenResponse deleteToken(String token, String email, String tenantName, String signature, String message) { DeleteTokenRequest request = DeleteTokenRequest.newBuilder().setToken(token).setTenantName(tenantName).setRequestSignature(signature).setMessage(message).build(); - return GrpcUtil.executeGrpcCall(tokenServiceBlockingStub, TokenServiceGrpc.TokenServiceBlockingStub::deleteToken, request, Constants.OP_DELETE_TOKEN); + return GrpcUtil.executeGrpcCall(tokenServiceBlockingStub, + TokenServiceGrpc.TokenServiceBlockingStub::deleteToken, request, Constants.OP_DELETE_TOKEN); } public TokenValidationResponse validateToken(String token, String tenantName, String signature, String message) { TokenValidationRequest request = TokenValidationRequest.newBuilder().setToken(token).setTenantName(tenantName).setRequestSignature(signature).setMessage(message).build(); - return GrpcUtil.executeGrpcCall(tokenServiceBlockingStub, TokenServiceGrpc.TokenServiceBlockingStub::validateToken, request, Constants.OP_VALIDATE_TOKEN); + return GrpcUtil.executeGrpcCall(tokenServiceBlockingStub, + TokenServiceGrpc.TokenServiceBlockingStub::validateToken, request, Constants.OP_VALIDATE_TOKEN); } public TokenValidationResponse validateUserToken(String token, String tenantName) { ValidateUserTokenRequest request = ValidateUserTokenRequest.newBuilder().setToken(token).setTenantName(tenantName != null ? tenantName : "").build(); - return GrpcUtil.executeGrpcCall(tokenServiceBlockingStub, TokenServiceGrpc.TokenServiceBlockingStub::validateUserToken, request, Constants.OP_VALIDATE_USER_TOKEN); + return GrpcUtil.executeGrpcCall(tokenServiceBlockingStub, + TokenServiceGrpc.TokenServiceBlockingStub::validateUserToken, request, Constants.OP_VALIDATE_USER_TOKEN); } public List listEntitlements(String tenantName, String signature, String message) { ListEntitlementsByTenantRequest request = ListEntitlementsByTenantRequest.newBuilder().setTenantName(tenantName).setSignature(signature).setMessage(message).build(); - ListEntitlementsByTenantResponse response = GrpcUtil.executeGrpcCall(entitlementServiceBlockingStub, EntitlementServiceGrpc.EntitlementServiceBlockingStub::listEntitlementsByTenant, request, Constants.OP_LIST_ENTITLEMENTS); + ListEntitlementsByTenantResponse response = GrpcUtil.executeGrpcCall(entitlementServiceBlockingStub, + EntitlementServiceGrpc.EntitlementServiceBlockingStub::listEntitlementsByTenant, request, Constants.OP_LIST_ENTITLEMENTS); return response.getEntitlementsList(); } public List listDastEntitlements(String tenantName, String signature, String message) { ListDastEntitlementsByTenantRequest request = ListDastEntitlementsByTenantRequest.newBuilder().setTenantName(tenantName).setSignature(signature).setMessage(message).build(); - ListDastEntitlementsByTenantResponse response = GrpcUtil.executeGrpcCall(dastEntitlementServiceBlockingStub, DastEntitlementServiceGrpc.DastEntitlementServiceBlockingStub::listDastEntitlementsByTenant, request, Constants.OP_LIST_DAST_ENTITLEMENTS); + ListDastEntitlementsByTenantResponse response = GrpcUtil.executeGrpcCall(dastEntitlementServiceBlockingStub, + DastEntitlementServiceGrpc.DastEntitlementServiceBlockingStub::listDastEntitlementsByTenant, + request, Constants.OP_LIST_DAST_ENTITLEMENTS); return response.getEntitlementsList(); } diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientHelper.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientHelper.java index d01c9d04586..c6045123392 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientHelper.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientHelper.java @@ -45,11 +45,20 @@ public class AviatorGrpcClientHelper { private static final String AVIATOR_MODULE = "aviator"; public static AviatorGrpcClient createClient(String url, IAviatorLogger logger, long pingIntervalSeconds) throws AviatorSimpleException { + return createClient(url, logger, pingIntervalSeconds, AviatorRequestPolicy.WAIT_FOR_READY); + } + + public static AviatorGrpcClient createClient(String url, IAviatorLogger logger, long pingIntervalSeconds, + AviatorRequestPolicy policy) throws AviatorSimpleException { if (url == null || url.trim().isEmpty()) { throw new AviatorSimpleException("Aviator URL cannot be null or empty."); } + return createClient(createConnectionPlan(url), logger, pingIntervalSeconds, policy); + } - var connectionPlan = createConnectionPlan(url); + /** Accepts an explicit route so callers need not resolve proxy settings again. */ + static AviatorGrpcClient createClient(AviatorConnectionPlan connectionPlan, IAviatorLogger logger, long pingIntervalSeconds, + AviatorRequestPolicy policy) { var proxyDescriptor = connectionPlan.proxyDescriptor(); var target = connectionPlan.target(); var builder = target.port() == null @@ -63,7 +72,8 @@ public static AviatorGrpcClient createClient(String url, IAviatorLogger logger, } var channel = configureBuilder(builder, proxyDescriptor).build(); - return new AviatorGrpcClient(channel, Constants.DEFAULT_TIMEOUT_SECONDS, logger, pingIntervalSeconds); + return new AviatorGrpcClient(channel, Constants.DEFAULT_TIMEOUT_SECONDS, logger, pingIntervalSeconds, + policy, AviatorConnectionContext.of(connectionPlan)); } public static AviatorConnectionPlan createConnectionPlan(String url) { @@ -79,27 +89,29 @@ public static AviatorConnectionPlan createConnectionPlan(String url) { public static ParsedTarget parseTarget(String url) { var normalizedUrl = normalizeUrl(url); + var safeUrl = toSafeUrlForDisplay(url); URI uri; try { uri = URI.create(normalizedUrl); - } catch (Exception e) { - throw new AviatorSimpleException("Aviator URL format is invalid. Expected 'host:port' or a valid target string. Provided URL: " + url, e); + } catch (IllegalArgumentException e) { + // URI parsing errors include the original URL, which may contain credentials. + throw new AviatorSimpleException("Invalid Aviator URL; expected a host and optional port: " + safeUrl); } var host = uri.getHost(); if ( host==null || host.isBlank() ) { - throw new AviatorSimpleException("Aviator URL is invalid: Host cannot be empty. Provided URL: " + url); + throw new AviatorSimpleException("Aviator URL is invalid: Host cannot be empty. Provided URL: " + safeUrl); } if ( uri.getPath()!=null && !uri.getPath().isBlank() && !"/".equals(uri.getPath()) ) { - LOG.warn("WARN: URL contained a path ('{}'), using only the host/port part. Full URL: {}", uri.getPath(), url); + LOG.warn("WARN: URL contained a path ('{}'), using only the host/port part. Full URL: {}", uri.getPath(), safeUrl); } var port = uri.getPort(); if ( port==-1 ) { return new ParsedTarget(host, null); } else if (port <= 0 || port > 65535) { - throw new AviatorSimpleException("Aviator URL is invalid: Invalid port number '"+port+"'. Provided URL: " + url); + throw new AviatorSimpleException("Aviator URL is invalid: Invalid port number '"+port+"'. Provided URL: " + safeUrl); } else { return new ParsedTarget(host, port); } @@ -121,7 +133,7 @@ public static String normalizeUrl(String url) { var schemeSeparator = trimmed.indexOf("://"); var scheme = trimmed.substring(0, schemeSeparator); if ( !"https".equalsIgnoreCase(scheme) ) { - throw new UnsupportedAviatorUrlSchemeException(scheme, url); + throw new UnsupportedAviatorUrlSchemeException(scheme, toSafeUrlForDisplay(url)); } if ( "https".equals(scheme) ) { return trimmed; @@ -130,6 +142,27 @@ public static String normalizeUrl(String url) { return "https"+trimmed.substring(schemeSeparator); } + /** + * Returns the URL with any user-info part (for example {@code user:password@}) replaced by + * {@code ***}, so that the URL can be shown in messages and logs. Other URLs stay unchanged. + */ + static String toSafeUrlForDisplay(String url) { + if ( url==null ) { + return null; + } + var schemeSeparator = url.indexOf("://"); + var authorityStart = schemeSeparator<0 ? 0 : schemeSeparator+3; + var authorityEnd = authorityStart; + while ( authorityEnd proxyDescriptor) { var configuredBuilder = builder .sslContext(createSslContext()) @@ -172,7 +205,11 @@ static ProxiedSocketAddress toProxiedSocketAddress(SocketAddress targetAddress, } public static AviatorGrpcClient createClient(String url) throws AviatorSimpleException { - return createClient(url, null, Constants.DEFAULT_PING_INTERVAL_SECONDS); + return createClient(url, AviatorRequestPolicy.WAIT_FOR_READY); + } + + public static AviatorGrpcClient createClient(String url, AviatorRequestPolicy policy) throws AviatorSimpleException { + return createClient(url, null, Constants.DEFAULT_PING_INTERVAL_SECONDS, policy); } public record AviatorConnectionPlan( diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorRequestPolicy.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorRequestPolicy.java new file mode 100644 index 00000000000..257a71b4165 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorRequestPolicy.java @@ -0,0 +1,36 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import java.util.Objects; + +import io.grpc.stub.AbstractBlockingStub; + +/** Unary request behavior; audit and diagnostic clients use {@link #WAIT_FOR_READY}. */ +public enum AviatorRequestPolicy { + FAIL_FAST, + WAIT_FOR_READY; + + boolean isWaitForReady() { + return this == WAIT_FOR_READY; + } + + /** Requires a fresh stub: gRPC's default is fail-fast, and wait-for-ready cannot be unset through the stub API. */ + > S configureNewStub(S stub, AviatorConnectionContext context) { + if ( isWaitForReady() ) { + return stub.withWaitForReady(); + } + return stub.withOption(AviatorConnectionContext.CALL_OPTION, Objects.requireNonNull(context, "connectionContext")); + } + +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/GrpcUtil.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/GrpcUtil.java index 1fc5785ca33..6e561935143 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/GrpcUtil.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/GrpcUtil.java @@ -12,10 +12,13 @@ */ package com.fortify.cli.aviator.grpc; -import java.util.*; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; +import org.apache.commons.lang3.exception.ExceptionUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -26,6 +29,7 @@ import com.fortify.aviator.grpc.Fragment; import com.fortify.aviator.grpc.IssueData; import com.fortify.aviator.grpc.StackTraceElementList; +import com.fortify.cli.aviator._common.exception.AviatorConnectivityException; import com.fortify.cli.aviator._common.exception.AviatorSimpleException; import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; import com.fortify.cli.aviator.audit.model.AuditResponse; @@ -52,59 +56,10 @@ interface GrpcCall { static , T, R> R executeGrpcCall(S stub, GrpcCall call, T request, String operation) { try { - S stubWithDeadline = stub.withDeadlineAfter(Constants.DEFAULT_TIMEOUT_SECONDS, TimeUnit.SECONDS); - return call.call(stubWithDeadline, request); + return call.call(stub.withDeadlineAfter(Constants.DEFAULT_TIMEOUT_SECONDS, TimeUnit.SECONDS), request); } catch (StatusRuntimeException e) { - Status status = e.getStatus(); - String serverDescription = status.getDescription() != null && !status.getDescription().isBlank() - ? status.getDescription() - : "No additional details were provided by the server."; - - switch (status.getCode()) { - case INVALID_ARGUMENT: - throw new AviatorSimpleException(String.format("Invalid input for %s. The server reported: %s. Please check the provided arguments.", operation, serverDescription)); - case NOT_FOUND: - throw new AviatorSimpleException(String.format("The requested resource was not found during %s. The server reported: %s. Please verify the name or ID is correct.", operation, serverDescription)); - case ALREADY_EXISTS: - throw new AviatorSimpleException(String.format("Cannot perform %s because a resource with the same identifier already exists. The server reported: %s.", operation, serverDescription)); - case FAILED_PRECONDITION: - throw new AviatorSimpleException(String.format("The %s operation could not be completed because a required condition was not met. The server reported: %s.", operation, serverDescription)); - - case PERMISSION_DENIED: - if (serverDescription.toLowerCase().contains("invalid signature")) { - throw new AviatorSimpleException("Permission Denied: Invalid signature. Please verify the private key in your admin configuration is correct and corresponds to the public key registered with Aviator."); - } else { - throw new AviatorSimpleException(String.format("Permission Denied for %s. You may not have the required roles for this action. Server details: %s", operation, serverDescription)); - } - case UNAUTHENTICATED: - throw new AviatorSimpleException("Authentication Failed: The token or credentials used are invalid or expired. Please log in again using 'fcli aviator session login' or verify your admin configuration."); - - case RESOURCE_EXHAUSTED: - throw new AviatorSimpleException(String.format("The server's resource limits were exceeded during %s. Please try again later or contact support. Server details: %s", operation, serverDescription)); - case DEADLINE_EXCEEDED: - String timeoutMessage = String.format( - "The %s operation timed out because the server did not respond in time.\n\n" + - "Please check the following:\n" + - " 1. The Aviator URL in your configuration is correct and reachable.\n" + - " 2. Your network connection is stable and any firewalls or proxies are properly configured.\n\n" + - "If the URL and network are correct, the server may be experiencing high load. Please try again later.", - operation - ); - throw new AviatorSimpleException(timeoutMessage); - - case CANCELLED: - case UNKNOWN: - case ABORTED: - case UNIMPLEMENTED: - case INTERNAL: - case UNAVAILABLE: - case DATA_LOSS: - default: - String techMessage = String.format("A technical error occurred on the server while performing the %s operation. Please check the fcli logs for details or contact support if the issue persists.", operation); - String logMessage = String.format("gRPC call for '%s' failed with status %s (%s): %s", operation, status.getCode(), status.getCode().name(), serverDescription); - LOG.error(logMessage, e); - throw new AviatorTechnicalException(techMessage, e); - } + throw toConnectivityException(e, stub.getCallOptions().getOption(AviatorConnectionContext.CALL_OPTION)) + .orElseGet(() -> toAviatorException(e, operation)); } catch (Exception e) { String errorMessage = "An unexpected client-side error occurred during the " + operation + " operation."; LOG.error(errorMessage, e); @@ -112,6 +67,77 @@ static , T, R> R executeGrpcCall(S stub, GrpcC } } + private static Optional toConnectivityException(StatusRuntimeException e, AviatorConnectionContext context) { + if ( context == null ) { + return Optional.empty(); + } + return AviatorConnectivityFailureKind.classify(e, context) + .map(kind -> { + // Cause messages can contain credentials, so log only the cause types. + LOG.debug("Aviator connectivity failure: kind={}, causes=[{}]", kind, getCauseTypes(e)); + return new AviatorConnectivityException(kind.formatMessage(context)); + }); + } + + private static String getCauseTypes(Throwable e) { + return ExceptionUtils.getThrowableList(e).stream() + .map(t -> t.getClass().getName()) + .collect(Collectors.joining(" <- ")); + } + + private static RuntimeException toAviatorException(StatusRuntimeException e, String operation) { + Status status = e.getStatus(); + String serverDescription = status.getDescription() != null && !status.getDescription().isBlank() + ? status.getDescription() + : "No additional details were provided by the server."; + + switch (status.getCode()) { + case INVALID_ARGUMENT: + return new AviatorSimpleException(String.format("Invalid input for %s. The server reported: %s. Please check the provided arguments.", operation, serverDescription)); + case NOT_FOUND: + return new AviatorSimpleException(String.format("The requested resource was not found during %s. The server reported: %s. Please verify the name or ID is correct.", operation, serverDescription)); + case ALREADY_EXISTS: + return new AviatorSimpleException(String.format("Cannot perform %s because a resource with the same identifier already exists. The server reported: %s.", operation, serverDescription)); + case FAILED_PRECONDITION: + return new AviatorSimpleException(String.format("The %s operation could not be completed because a required condition was not met. The server reported: %s.", operation, serverDescription)); + + case PERMISSION_DENIED: + if (serverDescription.toLowerCase().contains("invalid signature")) { + return new AviatorSimpleException("Permission Denied: Invalid signature. Please verify the private key in your admin configuration is correct and corresponds to the public key registered with Aviator."); + } else { + return new AviatorSimpleException(String.format("Permission Denied for %s. You may not have the required roles for this action. Server details: %s", operation, serverDescription)); + } + case UNAUTHENTICATED: + return new AviatorSimpleException("Authentication Failed: The token or credentials used are invalid or expired. Please log in again using 'fcli aviator session login' or verify your admin configuration."); + + case RESOURCE_EXHAUSTED: + return new AviatorSimpleException(String.format("Aviator server resource limits were exceeded during %s (server details: %s); try again later, and contact support if the problem continues", operation, serverDescription)); + case DEADLINE_EXCEEDED: + String timeoutMessage = String.format( + "The %s operation timed out because the server did not respond in time.\n\n" + + "Please check the following:\n" + + " 1. The Aviator URL in your configuration is correct and reachable.\n" + + " 2. Your network connection is stable and any firewalls or proxies are properly configured.\n\n" + + "If the URL and network are correct, the server may be experiencing high load. Please try again later.", + operation + ); + return new AviatorSimpleException(timeoutMessage); + + case CANCELLED: + case UNKNOWN: + case ABORTED: + case UNIMPLEMENTED: + case INTERNAL: + case UNAVAILABLE: + case DATA_LOSS: + default: + String techMessage = String.format("The Aviator %s operation failed with status %s; check the Aviator URL and network access, run the command again with --log-level DEBUG to log details, and contact support if the problem continues", operation, status.getCode()); + String logMessage = String.format("gRPC call for '%s' failed with status %s (%s): %s", operation, status.getCode(), status.getCode().name(), serverDescription); + LOG.error(logMessage, e); + return new AviatorTechnicalException(techMessage, e); + } + } + static AuditRequest convertToAuditRequest(UserPrompt userPrompt, String streamId, String requestId) { List stackTraceElementLists = new ArrayList<>(); if (userPrompt.getStackTrace() != null) { diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/exception/AviatorConnectivityExceptionTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/exception/AviatorConnectivityExceptionTest.java new file mode 100644 index 00000000000..0e7f83292aa --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/exception/AviatorConnectivityExceptionTest.java @@ -0,0 +1,95 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.exception; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.PrintWriter; +import java.io.StringWriter; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; +import com.fortify.cli.common.exception.FcliExecutionExceptionHandler; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.ThrowableProxyUtil; +import ch.qos.logback.core.read.ListAppender; +import picocli.CommandLine; +import picocli.CommandLine.Help.Ansi; +import picocli.CommandLine.Model.CommandSpec; + +class AviatorConnectivityExceptionTest { + private static final String SECRET = "sentinel-secret-value"; + + private final Logger logger = (Logger)LoggerFactory.getLogger(FcliExecutionExceptionHandler.class); + private final ListAppender appender = new ListAppender<>(); + private Level previousLevel; + + @BeforeEach + void setUp() { + previousLevel = logger.getLevel(); + logger.setLevel(Level.DEBUG); + appender.start(); + logger.addAppender(appender); + } + + @AfterEach + void tearDown() { + logger.detachAppender(appender); + logger.setLevel(previousLevel); + } + + @Test + void handlerPrintsTheMessageWithoutCause() { + var err = new StringWriter(); + var exception = new AviatorConnectivityException("Proxy localhost:33444 refused the connection; check the proxy host and port"); + + int exitCode = FcliExecutionExceptionHandler.INSTANCE.handleException(exception, createCommandLine(err)); + + assertEquals(1, exitCode); + assertNull(exception.getCause()); + assertFalse(err.toString().contains("Caused by")); + } + + @Test + void malformedUrlDoesNotExposeCredentialsInOutputOrLogs() { + var exception = assertThrows(AviatorSimpleException.class, + () -> AviatorGrpcClientHelper.parseTarget("https://user:" + SECRET + "@aviator.invalid/%zz")); + var err = new StringWriter(); + + assertEquals(1, FcliExecutionExceptionHandler.INSTANCE.handleException(exception, createCommandLine(err))); + + assertNull(exception.getCause()); + assertFalse(err.toString().contains(SECRET)); + assertEquals(1, appender.list.size()); + var event = appender.list.get(0); + assertFalse(event.getFormattedMessage().contains(SECRET)); + assertFalse(ThrowableProxyUtil.asString(event.getThrowableProxy()).contains(SECRET)); + } + + private static CommandLine createCommandLine(StringWriter err) { + var commandLine = new CommandLine(CommandSpec.create()); + commandLine.setErr(new PrintWriter(err)); + commandLine.setColorScheme(CommandLine.Help.defaultColorScheme(Ansi.OFF)); + return commandLine; + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorConnectionContextTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorConnectionContextTest.java new file mode 100644 index 00000000000..1062082a3b4 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorConnectionContextTest.java @@ -0,0 +1,77 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Optional; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper.AviatorConnectionPlan; +import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper.ParsedTarget; +import com.fortify.cli.common.http.proxy.helper.ProxyDescriptor; + +class AviatorConnectionContextTest { + private static final String PROXY_PASSWORD = "sentinel-proxy-password"; + + @Test + void createsDirectContextFromPlan() { + var context = AviatorConnectionContext.of(plan(Optional.empty())); + + assertFalse(context.isProxied()); + assertNull(context.proxy()); + assertEquals("aviator.example.com:443", context.target().display()); + } + + @Test + void createsProxiedContextWithoutCredentials() { + var proxy = ProxyDescriptor.builder() + .proxyHost("localhost").proxyPort(33444) + .proxyUser("sentinel-user").proxyPassword(PROXY_PASSWORD.toCharArray()) + .build(); + + var context = AviatorConnectionContext.of(plan(Optional.of(proxy))); + + assertTrue(context.isProxied()); + assertEquals("localhost:33444", context.proxy().display()); + assertFalse(context.toString().contains(PROXY_PASSWORD)); + assertFalse(context.toString().contains("sentinel-user")); + } + + @Test + void requiresTarget() { + assertThrows(NullPointerException.class, () -> new AviatorConnectionContext(null, null)); + } + + @Test + void replacesControlCharactersInHostNames() { + assertEquals("evil?host:1", new AviatorEndpoint("evil\u001bhost", 1).display()); + assertEquals("unknown:1", new AviatorEndpoint(null, 1).display()); + } + + @Test + void displaysIpv6Hosts() { + assertEquals("[::1]:443", new AviatorEndpoint("::1", 443).display()); + assertEquals("[::1]:443", new AviatorEndpoint("[::1]", 443).display()); + } + + private static AviatorConnectionPlan plan(Optional proxy) { + return new AviatorConnectionPlan("aviator.example.com", "https://aviator.example.com", + new ParsedTarget("aviator.example.com", null), 443, proxy); + } +} diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorConnectivityFailureKindTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorConnectivityFailureKindTest.java new file mode 100644 index 00000000000..61d26cc4ac5 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorConnectivityFailureKindTest.java @@ -0,0 +1,188 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.ConnectException; +import java.net.UnknownHostException; +import java.nio.channels.UnresolvedAddressException; +import java.security.cert.CertPathValidatorException; +import java.security.cert.CertificateException; +import java.util.Optional; + +import javax.net.ssl.SSLException; +import javax.net.ssl.SSLHandshakeException; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; + +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import io.grpc.netty.shaded.io.netty.channel.ConnectTimeoutException; +import io.grpc.netty.shaded.io.netty.handler.codec.http.DefaultHttpHeaders; +import io.grpc.netty.shaded.io.netty.handler.proxy.HttpProxyHandler.HttpProxyConnectException; +import io.grpc.netty.shaded.io.netty.handler.proxy.ProxyConnectException; + +class AviatorConnectivityFailureKindTest { + private static final AviatorConnectionContext DIRECT = + new AviatorConnectionContext(new AviatorEndpoint("aviator.example.com", 443), null); + private static final AviatorConnectionContext PROXIED = + new AviatorConnectionContext(new AviatorEndpoint("aviator.example.com", 443), new AviatorEndpoint("localhost", 33444)); + + @Test + void classifiesProxyRejectionBeforeOtherConnectExceptions() { + var cause = new HttpProxyConnectException("status: 407", new DefaultHttpHeaders()); + assertEquals(Optional.of(AviatorConnectivityFailureKind.PROXY_REJECTED), classify(Status.UNAVAILABLE, cause, PROXIED)); + } + + @Test + void classifiesOtherProxyFailuresAsNegotiationFailure() { + var cause = new ProxyConnectException("timeout"); + assertEquals(Optional.of(AviatorConnectivityFailureKind.PROXY_NEGOTIATION_FAILED), classify(Status.UNAVAILABLE, cause, PROXIED)); + } + + @Test + void classifiesConnectTimeoutBeforeRefusal() { + var cause = new ConnectTimeoutException("connection timed out"); + assertEquals(Optional.of(AviatorConnectivityFailureKind.CONNECT_TIMEOUT), classify(Status.UNAVAILABLE, cause, DIRECT)); + } + + @Test + void classifiesConnectExceptionAsProxyRefusalWhenProxied() { + var cause = new ConnectException("Connection refused"); + assertEquals(Optional.of(AviatorConnectivityFailureKind.PROXY_REFUSED), classify(Status.UNAVAILABLE, cause, PROXIED)); + } + + @ParameterizedTest + @ValueSource(strings = {"Connection refused", "Connection refused: getsockopt", "Connection refused: no further information"}) + void classifiesConnectExceptionAsDirectRefusalWhenNotProxied(String reason) { + var cause = new ConnectException(reason); + assertEquals(Optional.of(AviatorConnectivityFailureKind.DIRECT_REFUSED), classify(Status.UNAVAILABLE, cause, DIRECT)); + } + + @Test + void classifiesNameResolutionFailures() { + assertEquals(Optional.of(AviatorConnectivityFailureKind.DNS_FAILURE), + classify(Status.UNAVAILABLE, new UnknownHostException("host"), DIRECT)); + assertEquals(Optional.of(AviatorConnectivityFailureKind.DNS_FAILURE), + classify(Status.UNAVAILABLE, new UnresolvedAddressException(), PROXIED)); + } + + @Test + void usesNeutralKindsWithoutTransportEvidence() { + assertEquals(Optional.of(AviatorConnectivityFailureKind.UNAVAILABLE), classify(Status.UNAVAILABLE, null, DIRECT)); + assertEquals(Optional.of(AviatorConnectivityFailureKind.DEADLINE_EXCEEDED), classify(Status.DEADLINE_EXCEEDED, null, DIRECT)); + } + + @Test + void leavesCertificateFailuresToPkixGuidance() { + var pkix = new SSLHandshakeException("PKIX path building failed"); + pkix.initCause(new CertificateException("untrusted")); + assertEquals(Optional.empty(), classify(Status.UNAVAILABLE, pkix, DIRECT)); + assertEquals(Optional.empty(), classify(Status.UNAVAILABLE, new CertPathValidatorException("untrusted"), DIRECT)); + } + + @Test + void classifiesOtherTlsFailures() { + assertEquals(Optional.of(AviatorConnectivityFailureKind.TLS_FAILURE), + classify(Status.UNAVAILABLE, new SSLException("Unsupported or unrecognized SSL message"), PROXIED)); + assertEquals(Optional.of(AviatorConnectivityFailureKind.TLS_FAILURE), + classify(Status.UNAVAILABLE, new SSLHandshakeException("handshake timed out"), DIRECT)); + } + + @Test + void usesNeutralKindForProxyFailuresWithoutFcliProxy() { + var cause = new HttpProxyConnectException("status: 407", new DefaultHttpHeaders()); + assertEquals(Optional.of(AviatorConnectivityFailureKind.UNAVAILABLE), classify(Status.UNAVAILABLE, cause, DIRECT)); + assertEquals(Optional.of(AviatorConnectivityFailureKind.DEADLINE_EXCEEDED), + classify(Status.DEADLINE_EXCEEDED, new ProxyConnectException("timeout"), DIRECT)); + } + + @ParameterizedTest + @EnumSource(value = Status.Code.class, names = {"UNAVAILABLE", "DEADLINE_EXCEEDED"}, mode = EnumSource.Mode.EXCLUDE) + void ignoresOtherStatusCodes(Status.Code code) { + assertEquals(Optional.empty(), classify(Status.fromCode(code), new ConnectException("Connection refused"), DIRECT)); + } + + @Test + void terminatesOnCyclicCauseChain() { + var first = new RuntimeException("first"); + var second = new RuntimeException("second", first); + first.initCause(second); + + assertEquals(Optional.of(AviatorConnectivityFailureKind.UNAVAILABLE), classify(Status.UNAVAILABLE, first, DIRECT)); + } + + @Test + void ignoresNestedCertificateFailures() { + var cause = new RuntimeException(new CertificateException("untrusted")); + assertEquals(Optional.empty(), classify(Status.UNAVAILABLE, cause, DIRECT)); + } + + @Test + void doesNotInferRefusalFromAnAmbiguousConnectException() { + var cause = new ConnectException("Connection refused: openssl-handshake_failure.example/10.0.0.1:443"); + assertEquals(Optional.of(AviatorConnectivityFailureKind.UNAVAILABLE), classify(Status.UNAVAILABLE, cause, DIRECT)); + } + + @Test + void doesNotMisreportNetworkOrUnknownConnectFailuresAsRefusal() { + for ( var reason : new String[] {"Network is unreachable", "Connection timed out", "Permission denied", null} ) { + assertEquals(Optional.of(AviatorConnectivityFailureKind.UNAVAILABLE), + classify(Status.UNAVAILABLE, new ConnectException(reason), DIRECT)); + } + } + + @Test + void recognizesRefusalInsideNettyAnnotatedCause() { + var cause = new ConnectException("Connection refused: openssl-handshake_failure.example/10.0.0.1:443"); + cause.initCause(new ConnectException("Connection refused")); + assertEquals(Optional.of(AviatorConnectivityFailureKind.DIRECT_REFUSED), classify(Status.UNAVAILABLE, cause, DIRECT)); + } + + @ParameterizedTest + @EnumSource(AviatorConnectivityFailureKind.class) + void everyFailureHasASingleLineActionableMessage(AviatorConnectivityFailureKind kind) { + for ( var context : new AviatorConnectionContext[] {DIRECT, PROXIED} ) { + if ( !context.isProxied() && kind.name().startsWith("PROXY_") ) { continue; } + var message = kind.formatMessage(context); + assertEquals(1, message.lines().count(), message); + assertTrue(message.contains("; check ") || message.contains("; try "), message); + assertFalse(message.endsWith("."), message); + assertFalse(message.contains("null"), message); + } + } + + @Test + void messagesNameTheFailingEndpoint() { + assertEquals("Proxy localhost:33444 refused the connection; check the proxy host and port", + AviatorConnectivityFailureKind.PROXY_REFUSED.formatMessage(PROXIED)); + assertEquals("Aviator server aviator.example.com:443 refused the connection; check the Aviator URL", + AviatorConnectivityFailureKind.DIRECT_REFUSED.formatMessage(DIRECT)); + assertEquals("Timed out connecting to proxy localhost:33444; check network access and firewall rules", + AviatorConnectivityFailureKind.CONNECT_TIMEOUT.formatMessage(PROXIED)); + assertEquals("Aviator server aviator.example.com:443 did not respond within 30 seconds; " + + "check network access and proxy settings, or try again later", + AviatorConnectivityFailureKind.DEADLINE_EXCEEDED.formatMessage(DIRECT)); + } + + private static Optional classify(Status status, Throwable cause, AviatorConnectionContext context) { + var exception = new StatusRuntimeException(cause==null ? status : status.withCause(cause)); + return AviatorConnectivityFailureKind.classify(exception, context); + } +} diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientConnectivityTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientConnectivityTest.java new file mode 100644 index 00000000000..3332c0c62a7 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientConnectivityTest.java @@ -0,0 +1,331 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import com.fortify.aviator.application.ApplicationByTenantName; +import com.fortify.aviator.application.ApplicationList; +import com.fortify.aviator.application.ApplicationServiceGrpc; +import com.fortify.aviator.dastentitlement.DastEntitlementServiceGrpc; +import com.fortify.aviator.dastentitlement.ListDastEntitlementsByTenantRequest; +import com.fortify.aviator.dastentitlement.ListDastEntitlementsByTenantResponse; +import com.fortify.aviator.entitlement.EntitlementServiceGrpc; +import com.fortify.aviator.entitlement.ListEntitlementsByTenantRequest; +import com.fortify.aviator.entitlement.ListEntitlementsByTenantResponse; +import com.fortify.grpc.token.ListTokensRequest; +import com.fortify.grpc.token.ListTokensResponse; +import com.fortify.grpc.token.TokenServiceGrpc; +import com.fortify.cli.aviator._common.exception.AviatorConnectivityException; +import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper.AviatorConnectionPlan; +import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper.ParsedTarget; +import com.fortify.cli.common.http.proxy.helper.ProxyDescriptor; + +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.ClientCall; +import io.grpc.ClientInterceptor; +import io.grpc.ManagedChannel; +import io.grpc.EquivalentAddressGroup; +import io.grpc.NameResolver; +import io.grpc.MethodDescriptor; +import io.grpc.Server; +import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder; +import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder; +import io.grpc.stub.StreamObserver; + +/** + * Checks connectivity behavior against real Netty transports on the loopback interface. + */ +class AviatorGrpcClientConnectivityTest { + private static final Duration FAIL_FAST_LIMIT = Duration.ofSeconds(5); + private static final String PROXY_SECRET = "sentinel-proxy-secret"; + private static final String TARGET_HOST = "aviator.example.invalid"; + + private ServerSocket proxyServer; + private Server grpcServer; + private ManagedChannel testChannel; + + @AfterEach + void tearDown() throws Exception { + if ( proxyServer!=null ) { proxyServer.close(); } + if ( testChannel!=null ) { testChannel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); } + if ( grpcServer!=null ) { grpcServer.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); } + } + + @Test + void directRefusalFailsFastWithDirectRefusedMessage() throws Exception { + int port = unusedPort(); + var plan = new AviatorConnectionPlan("127.0.0.1:" + port, "https://127.0.0.1:" + port, + new ParsedTarget("127.0.0.1", port), port, Optional.empty()); + + var exception = callAndExpectConnectivityFailure(plan); + + assertEquals("Aviator server 127.0.0.1:" + port + " refused the connection; check the Aviator URL", + exception.getMessage()); + } + + @Test + void proxyRefusalFailsFastWithProxyRefusedMessage() throws Exception { + int proxyPort = unusedPort(); + + var exception = callAndExpectConnectivityFailure(proxiedPlan(proxyPort)); + + assertEquals("Proxy 127.0.0.1:" + proxyPort + " refused the connection; check the proxy host and port", + exception.getMessage()); + } + + @Test + void proxyAuthenticationRejectionIsReportedAsProxyRejection() throws Exception { + proxyServer = new ServerSocket(0, 50, InetAddress.getLoopbackAddress()); + startRejectingProxy(proxyServer); + + var exception = callAndExpectConnectivityFailure(proxiedPlan(proxyServer.getLocalPort())); + + assertTrue(exception.getMessage().startsWith("Proxy 127.0.0.1:" + proxyServer.getLocalPort() + " rejected"), + exception.getMessage()); + assertFalse(exception.getMessage().contains("sentinel-realm")); + } + + @Test + @Timeout(20) + void stalledProxyNegotiationEndsBeforeRequestDeadline() throws Exception { + proxyServer = new ServerSocket(0, 1, InetAddress.getLoopbackAddress()); + // Leave the TCP connection in the listen backlog without answering CONNECT. Netty's real + // proxy handshake timer must end the call, rather than the RPC deadline. + var started = System.nanoTime(); + try ( var client = AviatorGrpcClientHelper.createClient(proxiedPlan(proxyServer.getLocalPort()), + null, 30, AviatorRequestPolicy.FAIL_FAST) ) { + var exception = assertThrows(AviatorConnectivityException.class, + () -> client.listApplication("tenant", "signature", "message")); + assertTrue(exception.getMessage().startsWith("Could not connect to Aviator server " + TARGET_HOST + ":443 through proxy"), + exception.getMessage()); + } + assertTrue(Duration.ofNanos(System.nanoTime() - started).compareTo(Duration.ofSeconds(15)) < 0); + } + + @ParameterizedTest + @EnumSource(AviatorRequestPolicy.class) + void healthyUnaryServicesUseSelectedPolicyAndStreamsKeepWaiting(AviatorRequestPolicy policy) throws Exception { + var captured = new AtomicReference(); + try ( var client = createPlaintextClient(policy, captured) ) { + assertTrue(client.listApplication("tenant", "signature", "message").isEmpty()); + assertCallPolicy(policy, captured.get()); + assertEquals(ListTokensResponse.getDefaultInstance(), client.listTokens("email", "tenant", "signature", "message")); + assertCallPolicy(policy, captured.get()); + assertTrue(client.listEntitlements("tenant", "signature", "message").isEmpty()); + assertCallPolicy(policy, captured.get()); + assertTrue(client.listDastEntitlements("tenant", "signature", "message").isEmpty()); + assertCallPolicy(policy, captured.get()); + assertTrue(client.getCorrelationAsyncStub().getCallOptions().isWaitForReady()); + assertTrue(client.getDastAuditAsyncStub().getCallOptions().isWaitForReady()); + assertNull(client.getCorrelationAsyncStub().getCallOptions().getOption(AviatorConnectionContext.CALL_OPTION)); + assertNull(client.getDastAuditAsyncStub().getCallOptions().getOption(AviatorConnectionContext.CALL_OPTION)); + } + } + + @Test + @Timeout(15) + @SuppressWarnings("deprecation") // Per-channel resolver keeps the fixture out of the global registry. + void refusedFirstAddressFallsBackToHealthyAddress() throws Exception { + grpcServer = NettyServerBuilder.forAddress(new InetSocketAddress("127.0.0.1", 0)) + .addService(new EmptyApplicationService()).build().start(); + var refused = new InetSocketAddress("127.0.0.1", unusedPort()); + var healthy = new InetSocketAddress("127.0.0.1", grpcServer.getPort()); + testChannel = NettyChannelBuilder.forTarget("fixture:///localhost").usePlaintext() + .nameResolverFactory(new NameResolver.Factory() { + @Override + public String getDefaultScheme() { return "fixture"; } + + @Override + public NameResolver newNameResolver(URI targetUri, NameResolver.Args args) { + return new NameResolver() { + @Override + public String getServiceAuthority() { return "localhost"; } + + @Override + public void start(Listener2 listener) { + listener.onResult(ResolutionResult.newBuilder().setAddresses(List.of( + new EquivalentAddressGroup(refused), new EquivalentAddressGroup(healthy))).build()); + } + + @Override + public void shutdown() {} + }; + } + }).build(); + var context = new AviatorConnectionContext(new AviatorEndpoint("localhost", grpcServer.getPort()), null); + try ( var client = new AviatorGrpcClient(testChannel, 30, null, 30, AviatorRequestPolicy.FAIL_FAST, context) ) { + assertTrue(client.listApplication("tenant", "signature", "message").isEmpty()); + } + } + + private static void assertCallPolicy(AviatorRequestPolicy policy, CallOptions options) { + assertEquals(policy.isWaitForReady(), options.isWaitForReady()); + assertEquals(!policy.isWaitForReady(), options.getOption(AviatorConnectionContext.CALL_OPTION) != null); + assertDeadlineAtMostDefault(options); + } + + private static AviatorConnectivityException callAndExpectConnectivityFailure(AviatorConnectionPlan plan) { + try ( var client = AviatorGrpcClientHelper.createClient(plan, null, 30, AviatorRequestPolicy.FAIL_FAST) ) { + long start = System.nanoTime(); + var exception = assertThrows(AviatorConnectivityException.class, + () -> client.listApplication("tenant", "signature", "message")); + var elapsed = Duration.ofNanos(System.nanoTime() - start); + assertTrue(elapsed.compareTo(FAIL_FAST_LIMIT) < 0, "Call took " + elapsed); + assertNull(exception.getCause()); + assertFalse(exception.getMessage().contains(PROXY_SECRET)); + return exception; + } + } + + private static AviatorConnectionPlan proxiedPlan(int proxyPort) { + var proxy = ProxyDescriptor.builder() + .proxyHost("127.0.0.1").proxyPort(proxyPort) + .proxyUser("sentinel-user").proxyPassword(PROXY_SECRET.toCharArray()) + .build(); + return new AviatorConnectionPlan(TARGET_HOST, "https://" + TARGET_HOST, + new ParsedTarget(TARGET_HOST, null), 443, Optional.of(proxy)); + } + + private AviatorGrpcClient createPlaintextClient(AviatorRequestPolicy policy, AtomicReference captured) throws IOException { + grpcServer = NettyServerBuilder.forAddress(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0)) + .addService(new EmptyApplicationService()) + .addService(new EmptyTokenService()) + .addService(new EmptyEntitlementService()) + .addService(new EmptyDastEntitlementService()) + .build() + .start(); + testChannel = NettyChannelBuilder.forAddress(InetAddress.getLoopbackAddress().getHostAddress(), grpcServer.getPort()) + .usePlaintext() + .intercept(new CallOptionsCapture(captured)) + .build(); + var context = new AviatorConnectionContext(new AviatorEndpoint("127.0.0.1", grpcServer.getPort()), null); + return new AviatorGrpcClient(testChannel, 30, null, 30, policy, context); + } + + private static void assertDeadlineAtMostDefault(CallOptions callOptions) { + assertNotNull(callOptions.getDeadline()); + long remainingSeconds = callOptions.getDeadline().timeRemaining(TimeUnit.SECONDS); + assertTrue(remainingSeconds <= 30 && remainingSeconds > 20, "Deadline remaining: " + remainingSeconds); + } + + private static int unusedPort() throws IOException { + try ( var socket = new ServerSocket(0, 1, InetAddress.getLoopbackAddress()) ) { + return socket.getLocalPort(); + } + } + + private static void startRejectingProxy(ServerSocket server) { + var thread = new Thread(() -> { + while ( !server.isClosed() ) { + try ( Socket socket = server.accept() ) { + readRequestHeaders(socket.getInputStream()); + OutputStream out = socket.getOutputStream(); + out.write(("HTTP/1.1 407 Proxy Authentication Required\r\n" + + "Proxy-Authenticate: Basic realm=\"sentinel-realm\"\r\n" + + "Content-Length: 0\r\n\r\n").getBytes(StandardCharsets.US_ASCII)); + out.flush(); + } catch ( IOException e ) { + // Server socket closed at test end + } + } + }, "test-rejecting-proxy"); + thread.setDaemon(true); + thread.start(); + } + + private static void readRequestHeaders(InputStream in) throws IOException { + int matched = 0; + byte[] terminator = {'\r', '\n', '\r', '\n'}; + int b; + while ( matched responseObserver) { + responseObserver.onNext(ApplicationList.getDefaultInstance()); + responseObserver.onCompleted(); + } + } + + private static final class EmptyTokenService extends TokenServiceGrpc.TokenServiceImplBase { + @Override + public void listTokens(ListTokensRequest request, StreamObserver observer) { + observer.onNext(ListTokensResponse.getDefaultInstance()); + observer.onCompleted(); + } + } + + private static final class EmptyEntitlementService extends EntitlementServiceGrpc.EntitlementServiceImplBase { + @Override + public void listEntitlementsByTenant(ListEntitlementsByTenantRequest request, + StreamObserver observer) { + observer.onNext(ListEntitlementsByTenantResponse.getDefaultInstance()); + observer.onCompleted(); + } + } + + private static final class EmptyDastEntitlementService extends DastEntitlementServiceGrpc.DastEntitlementServiceImplBase { + @Override + public void listDastEntitlementsByTenant(ListDastEntitlementsByTenantRequest request, + StreamObserver observer) { + observer.onNext(ListDastEntitlementsByTenantResponse.getDefaultInstance()); + observer.onCompleted(); + } + } + + private static final class CallOptionsCapture implements ClientInterceptor { + private final AtomicReference captured; + + private CallOptionsCapture(AtomicReference captured) { + this.captured = captured; + } + + @Override + public ClientCall interceptCall(MethodDescriptor method, + CallOptions callOptions, Channel next) { + captured.set(callOptions); + return next.newCall(method, callOptions); + } + } +} diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientHelperTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientHelperTest.java index 91ceb215f87..d3f60b9a2e5 100644 --- a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientHelperTest.java +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientHelperTest.java @@ -13,6 +13,7 @@ package com.fortify.cli.aviator.grpc; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -23,8 +24,10 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.ValueSource; +import com.fortify.cli.aviator._common.exception.AviatorSimpleException; import com.fortify.cli.aviator._common.exception.UnsupportedAviatorUrlSchemeException; import com.fortify.cli.common.http.proxy.helper.ProxyDescriptor; @@ -118,4 +121,34 @@ void shouldIgnoreUnsupportedSocketAddressTypes() { assertNull(proxied); } + + @ParameterizedTest + @CsvSource({ + "https://user:sentinel-secret@aviator.invalid:443/path, https://***@aviator.invalid:443/path", + "user:sentinel-secret@aviator.invalid:443, ***@aviator.invalid:443", + "mttp://sentinel-secret@aviator.invalid, mttp://***@aviator.invalid", + "https://aviator.invalid:443/a@b, https://aviator.invalid:443/a@b", + "aviator.invalid:443, aviator.invalid:443" + }) + void shouldRemoveUserInfoFromDisplayedUrl(String url, String expected) { + assertEquals(expected, AviatorGrpcClientHelper.toSafeUrlForDisplay(url)); + } + + @Test + void shouldNotExposeUserInfoInInvalidPortMessage() { + var ex = assertThrows(AviatorSimpleException.class, + () -> AviatorGrpcClientHelper.parseTarget("https://user:sentinel-secret@aviator.invalid:99999")); + + assertFalse(ex.getMessage().contains("sentinel-secret")); + assertTrue(ex.getMessage().contains("***@aviator.invalid:99999")); + } + + @Test + void shouldNotExposeUserInfoInUnsupportedSchemeException() { + var ex = assertThrows(UnsupportedAviatorUrlSchemeException.class, + () -> AviatorGrpcClientHelper.parseTarget("http://user:sentinel-secret@aviator.invalid")); + + assertFalse(ex.getMessage().contains("sentinel-secret")); + assertFalse(ex.getProvidedUrl().contains("sentinel-secret")); + } } diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/GrpcUtilTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/GrpcUtilTest.java index 09285bae9e9..434e2154c0a 100644 --- a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/GrpcUtilTest.java +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/GrpcUtilTest.java @@ -13,17 +13,35 @@ package com.fortify.cli.aviator.grpc; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import java.net.ConnectException; +import java.security.cert.CertificateException; import java.util.List; import java.util.Set; +import javax.net.ssl.SSLHandshakeException; + import org.junit.jupiter.api.Test; +import com.fortify.aviator.application.ApplicationServiceGrpc; import com.fortify.aviator.grpc.AuditorResponse; +import com.fortify.cli.aviator._common.exception.AviatorConnectivityException; +import com.fortify.cli.aviator._common.exception.AviatorSimpleException; +import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; import com.fortify.cli.aviator.audit.model.AuditResponse; import com.fortify.cli.aviator.audit.model.AuditResponse.AuditSkipReason; import com.fortify.cli.aviator.audit.model.File; import com.fortify.cli.aviator.audit.model.UserPrompt; +import com.fortify.cli.common.exception.FcliExceptionHelper; + +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder; class GrpcUtilTest { @@ -58,4 +76,71 @@ void shouldClassifySkippedServerResponseWithoutInspectingMessage() { assertEquals(AuditSkipReason.SKIPPED_BY_AVIATOR, auditResponse.getAuditSkipReason()); } + + @Test + void waitForReadyPolicyKeepsCurrentUnavailableMapping() { + var e = assertThrows(AviatorTechnicalException.class, + () -> execute(Status.UNAVAILABLE.withCause(new ConnectException("Connection refused")), AviatorRequestPolicy.WAIT_FOR_READY)); + + assertEquals("The Aviator test operation failed with status UNAVAILABLE; check the Aviator URL and network access, " + + "run the command again with --log-level DEBUG to log details, and contact support if the problem continues", + e.getMessage()); + } + + @Test + void failFastPolicyClassifiesUnavailable() { + var e = assertThrows(AviatorConnectivityException.class, + () -> execute(Status.UNAVAILABLE.withCause(new ConnectException("Connection refused")), AviatorRequestPolicy.FAIL_FAST)); + + assertEquals("Aviator server 127.0.0.1:1 refused the connection; check the Aviator URL", e.getMessage()); + assertNull(e.getCause()); + } + + @Test + void failFastPolicyKeepsCertificateCauseForPkixGuidance() { + var e = assertThrows(AviatorTechnicalException.class, + () -> execute(Status.UNAVAILABLE.withCause(new SSLHandshakeException("PKIX path building failed") + .initCause(new CertificateException("untrusted"))), AviatorRequestPolicy.FAIL_FAST)); + + assertTrue(FcliExceptionHelper.formatException(e).contains("PKIX SSL certificate validation failed")); + } + + @Test + void failFastPolicyKeepsUnimplementedContractForOlderServerLoginFallback() { + var e = assertThrows(AviatorTechnicalException.class, + () -> execute(Status.UNIMPLEMENTED, AviatorRequestPolicy.FAIL_FAST)); + + var cause = assertInstanceOf(StatusRuntimeException.class, e.getCause()); + assertEquals(Status.Code.UNIMPLEMENTED, cause.getStatus().getCode()); + } + + @Test + void resourceExhaustedPutsRemediationBeforeSupport() { + var e = assertThrows(AviatorSimpleException.class, + () -> execute(Status.RESOURCE_EXHAUSTED.withDescription("quota"), AviatorRequestPolicy.FAIL_FAST)); + + assertEquals("Aviator server resource limits were exceeded during test (server details: quota); " + + "try again later, and contact support if the problem continues", e.getMessage()); + } + + @Test + void failFastPolicyKeepsApplicationStatusMapping() { + var e = assertThrows(AviatorSimpleException.class, + () -> execute(Status.NOT_FOUND.withDescription("app"), AviatorRequestPolicy.FAIL_FAST)); + + assertFalse(e instanceof AviatorConnectivityException); + assertTrue(e.getMessage().contains("not found")); + } + + private static void execute(Status status, AviatorRequestPolicy policy) { + var channel = NettyChannelBuilder.forAddress("127.0.0.1", 1).usePlaintext().build(); + try { + var context = new AviatorConnectionContext(new AviatorEndpoint("127.0.0.1", 1), null); + GrpcUtil.executeGrpcCall(policy.configureNewStub(ApplicationServiceGrpc.newBlockingStub(channel), context), + (stub, request) -> { throw status.asRuntimeException(); }, + "request", "test"); + } finally { + channel.shutdownNow(); + } + } } \ No newline at end of file diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/config/admin/cli/cmd/AviatorAdminConfigCreateCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/config/admin/cli/cmd/AviatorAdminConfigCreateCommand.java index d4ed177ca91..0bbd54e5601 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/config/admin/cli/cmd/AviatorAdminConfigCreateCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/config/admin/cli/cmd/AviatorAdminConfigCreateCommand.java @@ -19,6 +19,7 @@ import com.fortify.cli.aviator._common.config.admin.cli.mixin.AviatorAdminConfigNameArgGroup; import com.fortify.cli.aviator._common.config.admin.helper.AviatorAdminConfigDescriptor; import com.fortify.cli.aviator._common.config.admin.helper.AviatorAdminConfigHelper; +import com.fortify.cli.aviator.grpc.AviatorRequestPolicy; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.cli.common.session.cli.cmd.AbstractSessionLoginCommand; import com.fortify.cli.common.session.cli.mixin.ISessionNameSupplier; @@ -47,6 +48,12 @@ public ISessionNameSupplier getSessionNameSupplier() { protected void logoutBeforeNewLogin(String configName, AviatorAdminConfigDescriptor configDescriptor) { } + @Override + protected boolean isReplaceExistingSessionBeforeLogin() { + // login() validates the configuration first; keep the existing configuration if validation fails + return false; + } + @Override protected AviatorAdminConfigDescriptor login(String configName) { String privateKeyContents = configCreateOptions.getPrivateKeyResolver().getPrivateKeyContents(); @@ -55,7 +62,7 @@ protected AviatorAdminConfigDescriptor login(String configName) { configCreateOptions.getTenant(), privateKeyContents); LOG.info("Validating Aviator admin configuration with the Aviator server..."); - configHelper.validateConfig(configDescriptor); + configHelper.validateConfig(configDescriptor, AviatorRequestPolicy.FAIL_FAST); LOG.info("Aviator admin configuration validated successfully."); return configDescriptor; } diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/config/admin/helper/AviatorAdminConfigHelper.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/config/admin/helper/AviatorAdminConfigHelper.java index e7039f7e94b..ada7e58dff1 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/config/admin/helper/AviatorAdminConfigHelper.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/config/admin/helper/AviatorAdminConfigHelper.java @@ -15,6 +15,7 @@ import com.fortify.cli.aviator._common.util.AviatorSignatureUtils; import com.fortify.cli.aviator.grpc.AviatorGrpcClient; import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; +import com.fortify.cli.aviator.grpc.AviatorRequestPolicy; import com.fortify.cli.common.session.helper.AbstractSessionHelper; public class AviatorAdminConfigHelper extends AbstractSessionHelper { @@ -38,7 +39,11 @@ protected Class getSessionDescriptorType() { } public void validateConfig(AviatorAdminConfigDescriptor configDescriptor) { - try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) { + validateConfig(configDescriptor, AviatorRequestPolicy.WAIT_FOR_READY); + } + + public void validateConfig(AviatorAdminConfigDescriptor configDescriptor, AviatorRequestPolicy policy) { + try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl(), policy)) { String[] messageAndSignature = AviatorSignatureUtils.createMessageAndSignature(configDescriptor, configDescriptor.getTenant()); String message = messageAndSignature[0]; String signature = messageAndSignature[1]; diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/output/cli/cmd/AbstractAviatorAdminSessionOutputCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/output/cli/cmd/AbstractAviatorAdminSessionOutputCommand.java index 12ad4a13068..3742f89aea5 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/output/cli/cmd/AbstractAviatorAdminSessionOutputCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/output/cli/cmd/AbstractAviatorAdminSessionOutputCommand.java @@ -15,6 +15,9 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fortify.cli.aviator._common.config.admin.cli.mixin.AviatorAdminConfigDescriptorSupplier; import com.fortify.cli.aviator._common.config.admin.helper.AviatorAdminConfigDescriptor; +import com.fortify.cli.aviator.grpc.AviatorGrpcClient; +import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; +import com.fortify.cli.aviator.grpc.AviatorRequestPolicy; import com.fortify.cli.common.output.cli.cmd.AbstractOutputCommand; import com.fortify.cli.common.output.cli.cmd.IJsonNodeSupplier; @@ -33,4 +36,9 @@ public final JsonNode getJsonNode() { protected abstract JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor); public abstract boolean isSingular(); + + /** The caller must close the client. */ + protected final AviatorGrpcClient createGrpcClient(AviatorAdminConfigDescriptor configDescriptor) { + return AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl(), AviatorRequestPolicy.FAIL_FAST); + } } \ No newline at end of file diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/cli/cmd/AviatorUserSessionLoginCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/cli/cmd/AviatorUserSessionLoginCommand.java index 42f93eb2c10..afa45505416 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/cli/cmd/AviatorUserSessionLoginCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/cli/cmd/AviatorUserSessionLoginCommand.java @@ -25,6 +25,7 @@ import com.fortify.cli.aviator._common.session.user.helper.AviatorUserSessionDescriptor; import com.fortify.cli.aviator._common.session.user.helper.AviatorUserSessionHelper; import com.fortify.cli.aviator._common.util.AviatorJwtUtils; +import com.fortify.cli.aviator.grpc.AviatorRequestPolicy; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.cli.common.session.cli.cmd.AbstractSessionLoginCommand; @@ -49,6 +50,12 @@ public class AviatorUserSessionLoginCommand extends AbstractSessionLoginCommand< @Override protected void logoutBeforeNewLogin(String sessionName, AviatorUserSessionDescriptor sessionDescriptor) {} + @Override + protected boolean isReplaceExistingSessionBeforeLogin() { + // login() validates the token first; keep the existing session if validation fails + return false; + } + @Override protected AviatorUserSessionDescriptor login(String sessionName) { String resolvedToken = tokenResolver.getToken(); @@ -56,7 +63,8 @@ protected AviatorUserSessionDescriptor login(String sessionName) { LOG.info("Validating Aviator user token with the server..."); try { - var validationResponse = sessionHelper.validateToken(sessionLoginOptions.getAviatorUrl(), resolvedToken).response(); + var validationResponse = sessionHelper.validateToken(sessionLoginOptions.getAviatorUrl(), resolvedToken, + AviatorRequestPolicy.FAIL_FAST).response(); if (!validationResponse.getValid()) { String errorMsg = validationResponse.getErrorMessage(); diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/helper/AviatorUserSessionHelper.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/helper/AviatorUserSessionHelper.java index dbed50341e3..5031a227993 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/helper/AviatorUserSessionHelper.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/helper/AviatorUserSessionHelper.java @@ -14,6 +14,7 @@ import com.fortify.cli.aviator._common.util.AviatorJwtUtils; import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; +import com.fortify.cli.aviator.grpc.AviatorRequestPolicy; import com.fortify.cli.common.session.helper.AbstractSessionHelper; import com.fortify.grpc.token.TokenValidationResponse; @@ -42,8 +43,12 @@ public AviatorUserTokenValidationResult validateToken(AviatorUserSessionDescript } public AviatorUserTokenValidationResult validateToken(String aviatorUrl, String token) { + return validateToken(aviatorUrl, token, AviatorRequestPolicy.WAIT_FOR_READY); + } + + public AviatorUserTokenValidationResult validateToken(String aviatorUrl, String token, AviatorRequestPolicy policy) { var tenantName = AviatorJwtUtils.extractTenantNameFromToken(token); - try (var client = AviatorGrpcClientHelper.createClient(aviatorUrl)) { + try (var client = AviatorGrpcClientHelper.createClient(aviatorUrl, policy)) { var response = client.validateUserToken(token, tenantName); return new AviatorUserTokenValidationResult(tenantName, response); } diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppAddEntitlementCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppAddEntitlementCommand.java index a3436bc9e94..5978d488555 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppAddEntitlementCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppAddEntitlementCommand.java @@ -21,7 +21,6 @@ import com.fortify.cli.aviator._common.util.AviatorGrpcUtils; import com.fortify.cli.aviator._common.util.AviatorSignatureUtils; import com.fortify.cli.aviator.grpc.AviatorGrpcClient; -import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; @@ -37,7 +36,7 @@ public class AviatorAppAddEntitlementCommand extends AbstractAviatorAdminSession @Override protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) throws AviatorSimpleException, AviatorTechnicalException { - try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) { + try (AviatorGrpcClient client = createGrpcClient(configDescriptor)) { String[] messageAndSignature = AviatorSignatureUtils.createMessageAndSignature( configDescriptor, configDescriptor.getTenant(), diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppCreateCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppCreateCommand.java index 72380a8b9e5..a72e1d6bf33 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppCreateCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppCreateCommand.java @@ -23,7 +23,6 @@ import com.fortify.cli.aviator._common.util.AviatorGrpcUtils; import com.fortify.cli.aviator._common.util.AviatorSignatureUtils; import com.fortify.cli.aviator.grpc.AviatorGrpcClient; -import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; import com.fortify.cli.common.progress.cli.mixin.ProgressWriterFactoryMixin; @@ -42,7 +41,7 @@ public class AviatorAppCreateCommand extends AbstractAviatorAdminSessionOutputCo @Override protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) { - try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) { + try (AviatorGrpcClient client = createGrpcClient(configDescriptor)) { String[] messageAndSignature = createMessageAndSignature(configDescriptor); Application createdApplication = createApplication(client, configDescriptor, messageAndSignature); LOG.info("Application '{}' created successfully for tenant: {}", applicationName, configDescriptor.getTenant()); diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppDeleteCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppDeleteCommand.java index eb0c13fe327..fbcca2b56ca 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppDeleteCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppDeleteCommand.java @@ -25,7 +25,6 @@ import com.fortify.cli.aviator._common.output.cli.cmd.AbstractAviatorAdminSessionOutputCommand; import com.fortify.cli.aviator._common.util.AviatorSignatureUtils; import com.fortify.cli.aviator.grpc.AviatorGrpcClient; -import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import lombok.Getter; @@ -41,7 +40,7 @@ public class AviatorAppDeleteCommand extends AbstractAviatorAdminSessionOutputCo @Override protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) throws AviatorSimpleException, AviatorTechnicalException { - try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) { + try (AviatorGrpcClient client = createGrpcClient(configDescriptor)) { String[] messageAndSignature = createMessageAndSignature(configDescriptor); ApplicationResponseMessage response = deleteApplication(client, configDescriptor, messageAndSignature); JsonNode result = processDeleteResponse(response); diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppGetCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppGetCommand.java index b1f12d10984..006ea270168 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppGetCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppGetCommand.java @@ -29,7 +29,6 @@ import com.fortify.cli.aviator._common.util.AviatorGrpcUtils; import com.fortify.cli.aviator._common.util.AviatorSignatureUtils; import com.fortify.cli.aviator.grpc.AviatorGrpcClient; -import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import lombok.Getter; @@ -46,7 +45,7 @@ public class AviatorAppGetCommand extends AbstractAviatorAdminSessionOutputComma @Override protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) throws AviatorSimpleException, AviatorTechnicalException { - try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) { + try (AviatorGrpcClient client = createGrpcClient(configDescriptor)) { String[] messageAndSignature = createMessageAndSignature(configDescriptor); Application application = getApplication(client, configDescriptor, messageAndSignature); JsonNode response = processGetApplicationResponse(AviatorGrpcUtils.grpcToJsonNode(application)); diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppListCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppListCommand.java index 094f3dc91b7..7595274cf9b 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppListCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppListCommand.java @@ -30,7 +30,6 @@ import com.fortify.cli.aviator._common.util.AviatorGrpcUtils; import com.fortify.cli.aviator._common.util.AviatorSignatureUtils; import com.fortify.cli.aviator.grpc.AviatorGrpcClient; -import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import lombok.Getter; @@ -45,7 +44,7 @@ public class AviatorAppListCommand extends AbstractAviatorAdminSessionOutputComm @Override protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) throws AviatorSimpleException, AviatorTechnicalException { - try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) { + try (AviatorGrpcClient client = createGrpcClient(configDescriptor)) { String[] messageAndSignature = createMessageAndSignature(configDescriptor); List applications = listApplications(client, configDescriptor, messageAndSignature); return formatApplicationsArray(applications, configDescriptor.getTenant()); diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppUpdateCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppUpdateCommand.java index da0adf98f6c..7e7e034c0b5 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppUpdateCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppUpdateCommand.java @@ -29,7 +29,6 @@ import com.fortify.cli.aviator._common.util.AviatorGrpcUtils; import com.fortify.cli.aviator._common.util.AviatorSignatureUtils; import com.fortify.cli.aviator.grpc.AviatorGrpcClient; -import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; @@ -49,7 +48,7 @@ public class AviatorAppUpdateCommand extends AbstractAviatorAdminSessionOutputCo @Override protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) throws AviatorSimpleException, AviatorTechnicalException { - try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) { + try (AviatorGrpcClient client = createGrpcClient(configDescriptor)) { String[] messageAndSignature = createMessageAndSignature(configDescriptor); Application updatedApplication = updateApplication(client, configDescriptor, messageAndSignature); JsonNode response = processUpdateApplicationResponse(AviatorGrpcUtils.grpcToJsonNode(updatedApplication)); diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/entitlement/cli/cmd/AbstractAviatorSastEntitlementListCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/entitlement/cli/cmd/AbstractAviatorSastEntitlementListCommand.java index 5f9bfa4ea51..79500af4b48 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/entitlement/cli/cmd/AbstractAviatorSastEntitlementListCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/entitlement/cli/cmd/AbstractAviatorSastEntitlementListCommand.java @@ -26,7 +26,6 @@ import com.fortify.cli.aviator._common.util.AviatorGrpcUtils; import com.fortify.cli.aviator._common.util.AviatorSignatureUtils; import com.fortify.cli.aviator.grpc.AviatorGrpcClient; -import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; /** * Abstract base class for SAST entitlement list commands. Provides the shared @@ -38,7 +37,7 @@ public abstract class AbstractAviatorSastEntitlementListCommand extends Abstract @Override protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) { - try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) { + try (AviatorGrpcClient client = createGrpcClient(configDescriptor)) { var messageAndSignature = AviatorSignatureUtils.createMessageAndSignature(configDescriptor, configDescriptor.getTenant()); String message = messageAndSignature[0]; String signature = messageAndSignature[1]; diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/entitlement/cli/cmd/AviatorEntitlementListDastCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/entitlement/cli/cmd/AviatorEntitlementListDastCommand.java index 9e619dc1075..5cd8fe81fbd 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/entitlement/cli/cmd/AviatorEntitlementListDastCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/entitlement/cli/cmd/AviatorEntitlementListDastCommand.java @@ -27,7 +27,6 @@ import com.fortify.cli.aviator._common.util.AviatorGrpcUtils; import com.fortify.cli.aviator._common.util.AviatorSignatureUtils; import com.fortify.cli.aviator.grpc.AviatorGrpcClient; -import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; import lombok.Getter; import picocli.CommandLine.Command; @@ -40,7 +39,7 @@ public class AviatorEntitlementListDastCommand extends AbstractAviatorAdminSessi @Override protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) { - try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) { + try (AviatorGrpcClient client = createGrpcClient(configDescriptor)) { var messageAndSignature = AviatorSignatureUtils.createMessageAndSignature(configDescriptor, configDescriptor.getTenant()); String message = messageAndSignature[0]; String signature = messageAndSignature[1]; diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenCreateCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenCreateCommand.java index dee46793140..33b924138aa 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenCreateCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenCreateCommand.java @@ -32,7 +32,6 @@ import com.fortify.cli.aviator._common.util.AviatorGrpcUtils; import com.fortify.cli.aviator._common.util.AviatorSignatureUtils; import com.fortify.cli.aviator.grpc.AviatorGrpcClient; -import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; import com.fortify.cli.aviator.util.FileUtil; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; @@ -57,7 +56,7 @@ public class AviatorTokenCreateCommand extends AbstractAviatorAdminSessionOutput @Override protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) throws AviatorSimpleException, AviatorTechnicalException { - try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) { + try (AviatorGrpcClient client = createGrpcClient(configDescriptor)) { String[] messageAndSignature = createMessageAndSignature(configDescriptor); TokenGenerationResponse response = generateToken(client, configDescriptor, messageAndSignature); return processTokenResponse(response); diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenDeleteCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenDeleteCommand.java index aa2aa814b24..aef69d516b8 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenDeleteCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenDeleteCommand.java @@ -25,7 +25,6 @@ import com.fortify.cli.aviator._common.session.user.cli.mixin.AviatorUserTokenResolverMixin; import com.fortify.cli.aviator._common.util.AviatorSignatureUtils; import com.fortify.cli.aviator.grpc.AviatorGrpcClient; -import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.grpc.token.DeleteTokenResponse; @@ -45,7 +44,7 @@ public class AviatorTokenDeleteCommand extends AbstractAviatorAdminSessionOutput protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) throws AviatorSimpleException, AviatorTechnicalException { String tokenToDelete = tokenResolver.getToken(); - try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) { + try (AviatorGrpcClient client = createGrpcClient(configDescriptor)) { String[] messageAndSignature = createMessageAndSignature(configDescriptor, tokenToDelete); DeleteTokenResponse response = deleteToken(client, configDescriptor, messageAndSignature, tokenToDelete); return processDeleteResponse(response, tokenToDelete); diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenListCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenListCommand.java index 08f450ebe20..c03df4cf811 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenListCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenListCommand.java @@ -29,7 +29,6 @@ import com.fortify.cli.aviator._common.util.AviatorGrpcUtils; import com.fortify.cli.aviator._common.util.AviatorSignatureUtils; import com.fortify.cli.aviator.grpc.AviatorGrpcClient; -import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; import com.fortify.cli.aviator.util.StringUtil; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.grpc.token.ListTokensResponse; @@ -50,7 +49,7 @@ public class AviatorTokenListCommand extends AbstractAviatorAdminSessionOutputCo @Override protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) throws AviatorSimpleException, AviatorTechnicalException { - try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) { + try (AviatorGrpcClient client = createGrpcClient(configDescriptor)) { ArrayNode tokensArray = fetchAllTokens(client, configDescriptor); logTokenCount(tokensArray.size()); return tokensArray; diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenRevokeCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenRevokeCommand.java index e85fc311334..258fa698b5c 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenRevokeCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenRevokeCommand.java @@ -25,7 +25,6 @@ import com.fortify.cli.aviator._common.session.user.cli.mixin.AviatorUserTokenResolverMixin; import com.fortify.cli.aviator._common.util.AviatorSignatureUtils; import com.fortify.cli.aviator.grpc.AviatorGrpcClient; -import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.grpc.token.RevokeTokenResponse; @@ -45,7 +44,7 @@ public class AviatorTokenRevokeCommand extends AbstractAviatorAdminSessionOutput protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) throws AviatorSimpleException, AviatorTechnicalException { String tokenToRevoke = tokenResolver.getToken(); - try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) { + try (AviatorGrpcClient client = createGrpcClient(configDescriptor)) { String[] messageAndSignature = createMessageAndSignature(configDescriptor, tokenToRevoke); RevokeTokenResponse response = revokeToken(client, configDescriptor, messageAndSignature, tokenToRevoke); return processRevokeResponse(response, tokenToRevoke); diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenValidateCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenValidateCommand.java index 5b682f3af97..84c798d42ee 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenValidateCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenValidateCommand.java @@ -22,7 +22,6 @@ import com.fortify.cli.aviator._common.session.user.cli.mixin.AviatorUserTokenResolverMixin; import com.fortify.cli.aviator._common.util.AviatorSignatureUtils; import com.fortify.cli.aviator.grpc.AviatorGrpcClient; -import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.grpc.token.TokenValidationResponse; @@ -39,7 +38,7 @@ public class AviatorTokenValidateCommand extends AbstractAviatorAdminSessionOutp protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) throws AviatorSimpleException, AviatorTechnicalException { String tokenToValidate = tokenResolver.getToken(); - try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) { + try (AviatorGrpcClient client = createGrpcClient(configDescriptor)) { String[] messageAndSignature = createMessageAndSignature(configDescriptor, tokenToValidate); TokenValidationResponse response = validateToken(client, configDescriptor, messageAndSignature, tokenToValidate); return createResponseNode(response); diff --git a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/_common/output/cli/cmd/AviatorOrdinaryCommandConnectivityTest.java b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/_common/output/cli/cmd/AviatorOrdinaryCommandConnectivityTest.java new file mode 100644 index 00000000000..e6884ad1a1d --- /dev/null +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/_common/output/cli/cmd/AviatorOrdinaryCommandConnectivityTest.java @@ -0,0 +1,136 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.output.cli.cmd; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Base64; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; +import org.junit.jupiter.api.io.TempDir; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fortify.cli.aviator._common.config.admin.helper.AviatorAdminConfigDescriptor; +import com.fortify.cli.aviator._common.exception.AviatorConnectivityException; +import com.fortify.cli.aviator._common.session.user.helper.AviatorUserSessionHelper; +import com.fortify.cli.aviator.grpc.AviatorRequestPolicy; +import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; +import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; +import com.fortify.cli.common.output.cli.mixin.IOutputHelper; + +/** + * Checks that fail-fast Aviator commands fail fast with the shared connectivity messages. + * Each case targets a closed loopback port, so no Aviator server is needed. + */ +class AviatorOrdinaryCommandConnectivityTest { + private static final String FCLI_DATA_DIR_PROPERTY = "fcli.env.FCLI_DATA_DIR"; + private static final Duration FAIL_FAST_LIMIT = Duration.ofSeconds(5); + + // Static, because some fcli helpers cache paths below the fcli data directory in static fields + @TempDir static Path fcliDataDir; + private static String previousFcliDataDir; + private String closedPortUrl; + + @BeforeAll + static void setUpFcliDataDir() { + previousFcliDataDir = System.getProperty(FCLI_DATA_DIR_PROPERTY); + System.setProperty(FCLI_DATA_DIR_PROPERTY, fcliDataDir.toString()); + } + + @AfterAll + static void restoreFcliDataDir() { + if ( previousFcliDataDir==null ) { + System.clearProperty(FCLI_DATA_DIR_PROPERTY); + } else { + System.setProperty(FCLI_DATA_DIR_PROPERTY, previousFcliDataDir); + } + } + + @BeforeEach + void setUp() throws IOException { + FcliExecutionContextHolder.pushNew(); + try ( var socket = new ServerSocket(0, 1, InetAddress.getLoopbackAddress()) ) { + closedPortUrl = "127.0.0.1:" + socket.getLocalPort(); + } + assumeTrue(AviatorGrpcClientHelper.createConnectionPlan(closedPortUrl).proxyDescriptor().isEmpty(), + "This test requires a direct loopback route (configure NO_PROXY for 127.0.0.1)"); + } + + @AfterEach + void tearDown() { + FcliExecutionContextHolder.pop(); + } + + @Test + void adminCommandBaseClassCreatesFailFastClient() { + var descriptor = new AviatorAdminConfigDescriptor(closedPortUrl, "tenant", "unused-private-key"); + var command = new TestAdminCommand(); + + assertFailsFast(() -> { + try ( var client = command.createGrpcClient(descriptor) ) { + client.listApplication("tenant", "signature", "message"); + } + }); + } + + @Test + void userSessionTokenValidationFailsFastWithFailFastPolicy() { + assertFailsFast(() -> AviatorUserSessionHelper.instance() + .validateToken(closedPortUrl, syntheticToken(), AviatorRequestPolicy.FAIL_FAST)); + } + + private static String syntheticToken() { + var encoder = Base64.getUrlEncoder().withoutPadding(); + return encoder.encodeToString("{\"alg\":\"none\"}".getBytes(StandardCharsets.UTF_8)) + + "." + encoder.encodeToString("{\"tenantName\":\"tenant\"}".getBytes(StandardCharsets.UTF_8)) + + ".signature"; + } + + private static void assertFailsFast(Executable executable) { + long start = System.nanoTime(); + var exception = assertThrows(AviatorConnectivityException.class, executable); + var elapsed = Duration.ofNanos(System.nanoTime() - start); + assertTrue(elapsed.compareTo(FAIL_FAST_LIMIT) < 0, "Call took " + elapsed); + assertTrue(exception.getMessage().contains("refused the connection"), exception.getMessage()); + } + + private static final class TestAdminCommand extends AbstractAviatorAdminSessionOutputCommand { + @Override + protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) { + return null; + } + + @Override + public boolean isSingular() { + return true; + } + + @Override + public IOutputHelper getOutputHelper() { + return null; + } + } +} diff --git a/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/session/cli/cmd/AbstractSessionLoginCommand.java b/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/session/cli/cmd/AbstractSessionLoginCommand.java index ce93114f719..8ca29538306 100644 --- a/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/session/cli/cmd/AbstractSessionLoginCommand.java +++ b/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/session/cli/cmd/AbstractSessionLoginCommand.java @@ -28,7 +28,9 @@ public JsonNode getJsonNode() { var sessionNameSupplier = getSessionNameSupplier(); String sessionName = sessionNameSupplier==null?"default":sessionNameSupplier.getSessionName(); var sessionHelper = getSessionHelper(); - logoutIfSessionExists(sessionName); + if ( isReplaceExistingSessionBeforeLogin() ) { + logoutIfSessionExists(sessionName); + } D sessionDescriptor = login(sessionName); sessionHelper.save(sessionName, sessionDescriptor); testAuthenticatedConnection(sessionName); @@ -63,4 +65,12 @@ private void logoutIfSessionExists(String sessionName) { protected abstract void logoutBeforeNewLogin(String sessionName, D sessionDescriptor); protected abstract D login(String sessionName); protected void testAuthenticatedConnection(String sessionName) {} + + /** + * Return false when login only validates credentials and the old session needs no remote logout. + * This keeps the existing session if validation fails; save replaces it after success. + */ + protected boolean isReplaceExistingSessionBeforeLogin() { + return true; + } } diff --git a/fcli-core/fcli-common-core/src/test/java/com/fortify/cli/common/session/cli/cmd/AbstractSessionLoginCommandTest.java b/fcli-core/fcli-common-core/src/test/java/com/fortify/cli/common/session/cli/cmd/AbstractSessionLoginCommandTest.java new file mode 100644 index 00000000000..7c0356ed398 --- /dev/null +++ b/fcli-core/fcli-common-core/src/test/java/com/fortify/cli/common/session/cli/cmd/AbstractSessionLoginCommandTest.java @@ -0,0 +1,206 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.common.session.cli.cmd; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Path; +import java.util.Date; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.output.cli.mixin.IOutputHelper; +import com.fortify.cli.common.session.cli.mixin.ISessionNameSupplier; +import com.fortify.cli.common.session.helper.AbstractSessionHelper; +import com.fortify.cli.common.session.helper.ISessionDescriptor; + +class AbstractSessionLoginCommandTest { + private static final String FCLI_DATA_DIR_PROPERTY = "fcli.env.FCLI_DATA_DIR"; + private static final String SESSION_NAME = "test-session"; + + @TempDir Path fcliDataDir; + private String previousFcliDataDir; + private final TestSessionHelper sessionHelper = new TestSessionHelper(); + + @BeforeEach + void setUp() { + previousFcliDataDir = System.getProperty(FCLI_DATA_DIR_PROPERTY); + System.setProperty(FCLI_DATA_DIR_PROPERTY, fcliDataDir.toString()); + FcliExecutionContextHolder.pushNew(); + } + + @AfterEach + void tearDown() { + FcliExecutionContextHolder.pop(); + if ( previousFcliDataDir==null ) { + System.clearProperty(FCLI_DATA_DIR_PROPERTY); + } else { + System.setProperty(FCLI_DATA_DIR_PROPERTY, previousFcliDataDir); + } + } + + @Test + void defaultLifecycleDestroysExistingSessionBeforeLogin() { + sessionHelper.save(SESSION_NAME, new TestSessionDescriptor("old")); + var command = new TestLoginCommand(sessionHelper, true, null); + + assertThrows(FcliSimpleException.class, command::getJsonNode); + + assertFalse(sessionHelper.exists(SESSION_NAME)); + assertEquals(1, command.logoutCount); + } + + @Test + void optOutKeepsExistingSessionWhenLoginFails() { + sessionHelper.save(SESSION_NAME, new TestSessionDescriptor("old")); + var command = new TestLoginCommand(sessionHelper, false, null); + + assertThrows(FcliSimpleException.class, command::getJsonNode); + + assertTrue(sessionHelper.exists(SESSION_NAME)); + assertEquals("old", sessionHelper.get(SESSION_NAME, true).getValue()); + assertEquals(0, command.logoutCount); + } + + @Test + void optOutCreatesNoSessionWhenLoginFailsForNewName() { + var command = new TestLoginCommand(sessionHelper, false, null); + + assertThrows(FcliSimpleException.class, command::getJsonNode); + + assertFalse(sessionHelper.exists(SESSION_NAME)); + } + + @Test + void optOutReplacesExistingSessionWhenLoginSucceeds() { + sessionHelper.save(SESSION_NAME, new TestSessionDescriptor("old")); + var command = new TestLoginCommand(sessionHelper, false, new TestSessionDescriptor("new")); + + command.getJsonNode(); + + assertEquals("new", sessionHelper.get(SESSION_NAME, true).getValue()); + assertEquals(0, command.logoutCount); + } + + private static final class TestLoginCommand extends AbstractSessionLoginCommand { + private final TestSessionHelper sessionHelper; + private final boolean replaceExistingSessionBeforeLogin; + private final TestSessionDescriptor loginResult; + private int logoutCount; + + private TestLoginCommand(TestSessionHelper sessionHelper, boolean replaceExistingSessionBeforeLogin, + TestSessionDescriptor loginResult) { + this.sessionHelper = sessionHelper; + this.replaceExistingSessionBeforeLogin = replaceExistingSessionBeforeLogin; + this.loginResult = loginResult; + } + + @Override + public ISessionNameSupplier getSessionNameSupplier() { + return () -> SESSION_NAME; + } + + @Override + protected void logoutBeforeNewLogin(String sessionName, TestSessionDescriptor sessionDescriptor) { + logoutCount++; + } + + @Override + protected TestSessionDescriptor login(String sessionName) { + if ( loginResult==null ) { + throw new FcliSimpleException("Validation failed"); + } + return loginResult; + } + + @Override + protected boolean isReplaceExistingSessionBeforeLogin() { + return replaceExistingSessionBeforeLogin; + } + + @Override + protected AbstractSessionHelper getSessionHelper() { + return sessionHelper; + } + + @Override + public IOutputHelper getOutputHelper() { + return null; + } + } + + private static final class TestSessionHelper extends AbstractSessionHelper { + @Override + public String getType() { + return "test-login"; + } + + @Override + protected String getLoginCmd() { + return "fcli test session login"; + } + + @Override + protected Class getSessionDescriptorType() { + return TestSessionDescriptor.class; + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class TestSessionDescriptor implements ISessionDescriptor { + private String value; + + public TestSessionDescriptor() {} + + TestSessionDescriptor(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + @Override + public String getUrlDescriptor() { + return "test"; + } + + @Override + public Date getCreatedDate() { + return new Date(); + } + + @Override + public Date getExpiryDate() { + return null; + } + + @Override + public String getType() { + return "test-login"; + } + } +}