diff --git a/framework/src/main/java/org/tron/core/services/filter/CachedBodyRequestWrapper.java b/framework/src/main/java/org/tron/core/services/filter/CachedBodyRequestWrapper.java deleted file mode 100644 index 683fe849f71..00000000000 --- a/framework/src/main/java/org/tron/core/services/filter/CachedBodyRequestWrapper.java +++ /dev/null @@ -1,97 +0,0 @@ -package org.tron.core.services.filter; - -import java.io.BufferedReader; -import java.io.ByteArrayInputStream; -import java.io.InputStreamReader; -import java.nio.charset.Charset; -import java.nio.charset.IllegalCharsetNameException; -import java.nio.charset.StandardCharsets; -import java.nio.charset.UnsupportedCharsetException; -import javax.servlet.ReadListener; -import javax.servlet.ServletInputStream; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletRequestWrapper; - -/** - * Wraps a request to replay a pre-read body from a byte array, - * allowing the body to be read more than once. - * - *

Scope: designed for synchronous, raw-body POST endpoints - * (e.g. JSON-RPC). It is NOT compatible with: - *

- * - *

Multiple calls to {@code getInputStream()} (or {@code getReader()}) - * are allowed and each returns a fresh stream over the same cached body — - * a deliberate extension of the standard servlet contract. - */ -public class CachedBodyRequestWrapper extends HttpServletRequestWrapper { - - private enum BodyAccessor { NONE, STREAM, READER } - - private final byte[] body; - private BodyAccessor accessor = BodyAccessor.NONE; - - public CachedBodyRequestWrapper(HttpServletRequest request, byte[] body) { - super(request); - this.body = body; - } - - @Override - public ServletInputStream getInputStream() { - if (accessor == BodyAccessor.READER) { - throw new IllegalStateException("getReader() has already been called on this request"); - } - accessor = BodyAccessor.STREAM; - final ByteArrayInputStream bais = new ByteArrayInputStream(body); - return new ServletInputStream() { - @Override - public int read() { - return bais.read(); - } - - @Override - public int read(byte[] b, int off, int len) { - return bais.read(b, off, len); - } - - @Override - public boolean isFinished() { - return bais.available() == 0; - } - - @Override - public boolean isReady() { - return true; - } - - @Override - public void setReadListener(ReadListener readListener) { - throw new UnsupportedOperationException( - "async I/O is not supported on cached body"); - } - }; - } - - @Override - public BufferedReader getReader() { - if (accessor == BodyAccessor.STREAM) { - throw new IllegalStateException("getInputStream() has already been called on this request"); - } - accessor = BodyAccessor.READER; - String encoding = getCharacterEncoding(); - Charset charset; - try { - charset = encoding != null ? Charset.forName(encoding) : StandardCharsets.UTF_8; - } catch (IllegalCharsetNameException | UnsupportedCharsetException ex) { - charset = StandardCharsets.UTF_8; - } - return new BufferedReader(new InputStreamReader(new ByteArrayInputStream(body), charset)); - } -} diff --git a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcErrorResolver.java b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcErrorResolver.java index b92b3cf1af6..b915de149cf 100644 --- a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcErrorResolver.java +++ b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcErrorResolver.java @@ -1,50 +1,140 @@ package org.tron.core.services.jsonrpc; import com.fasterxml.jackson.databind.JsonNode; -import com.googlecode.jsonrpc4j.ErrorData; import com.googlecode.jsonrpc4j.ErrorResolver; import com.googlecode.jsonrpc4j.JsonRpcError; import com.googlecode.jsonrpc4j.JsonRpcErrors; +import com.googlecode.jsonrpc4j.ProxyUtil; import com.googlecode.jsonrpc4j.ReflectionUtil; import java.lang.reflect.Method; import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.tron.core.exception.TronError; import org.tron.core.exception.jsonrpc.JsonRpcException; /** * {@link ErrorResolver} that uses annotations. */ +@Slf4j(topic = "API") public enum JsonRpcErrorResolver implements ErrorResolver { INSTANCE; + private static final Set SEEN_FAILURES = ConcurrentHashMap.newKeySet(); + /** * {@inheritDoc} */ @Override public JsonError resolveError( Throwable thrownException, Method method, List arguments) { - JsonRpcError resolver = getResolverForException(thrownException, method); - if (notFoundResolver(resolver)) { - return null; + Error fatal = findFatalCause(thrownException); + if (fatal != null) { + throw fatal; + } + + JsonRpcError resolver = method == null + ? null : getResolverForException(thrownException, method); + if (resolver == null) { + logUnhandledException(method, thrownException); + return new JsonError(JsonError.INTERNAL_ERROR.code, "Internal error", null); } String message = hasErrorMessage(resolver) ? resolver.message() : thrownException.getMessage(); + if (StringUtils.isBlank(message)) { + message = defaultMessageFor(resolver.code()); + } - // data priority: exception > annotation > default ErrorData + // data priority: exception > annotation Object data = null; if (thrownException instanceof JsonRpcException) { JsonRpcException jsonRpcException = (JsonRpcException) thrownException; data = jsonRpcException.getData(); } - if (data == null) { - data = hasErrorData(resolver) - ? resolver.data() - : new ErrorData(resolver.exception().getName(), message); + if (data == null && hasErrorData(resolver)) { + data = resolver.data(); } - + return new JsonError(resolver.code(), message, data); } + static Error findFatalCause(Throwable throwable) { + // Avoid allocations when handling memory exhaustion, without truncating deep cause chains. + // Check both fast-pointer steps so cycle detection cannot skip a fatal cause inside a cycle. + Throwable slow = throwable; + Throwable fast = throwable; + while (fast != null) { + if (isFatal(fast)) { + return (Error) fast; + } + fast = fast.getCause(); + if (fast == null) { + return null; + } + if (isFatal(fast)) { + return (Error) fast; + } + fast = fast.getCause(); + slow = slow.getCause(); + if (fast == slow) { + return null; + } + } + return null; + } + + private static boolean isFatal(Throwable cause) { + return cause instanceof VirtualMachineError + || cause instanceof ThreadDeath + || cause instanceof LinkageError + || cause instanceof TronError; + } + + private static void logUnhandledException(Method method, Throwable thrownException) { + String methodName = rpcMethodName(method); + String exceptionName = thrownException.getClass().getName(); + String key = methodName + '\0' + exceptionName; + if (SEEN_FAILURES.add(key)) { + // The first occurrence retains the Throwable for diagnosis. Repeated failures omit both + // the stack and exception message so a request loop cannot flood the WARN log. + logger.warn("Unhandled exception in JSON-RPC method {}", methodName, thrownException); + } else { + logger.debug("Repeated unhandled exception in JSON-RPC method {} ({})", + methodName, exceptionName); + } + } + + static void clearSeenFailuresForTest() { + SEEN_FAILURES.clear(); + } + + private static String rpcMethodName(Method method) { + if (method == null) { + return "unknown"; + } + try { + return ProxyUtil.getMethodName(method); + } catch (RuntimeException e) { + return method.getName(); + } + } + + private static String defaultMessageFor(int code) { + switch (code) { + case -32600: + return "Invalid Request"; + case -32601: + return "Method not found"; + case -32602: + return "Invalid params"; + default: + return "Internal error"; + } + } + private JsonRpcError getResolverForException(Throwable thrownException, Method method) { JsonRpcErrors errors = ReflectionUtil.getAnnotation(method, JsonRpcErrors.class); if (hasAnnotations(errors)) { @@ -57,10 +147,6 @@ private JsonRpcError getResolverForException(Throwable thrownException, Method m return null; } - private boolean notFoundResolver(JsonRpcError resolver) { - return resolver == null; - } - private boolean hasErrorMessage(JsonRpcError em) { // noinspection ConstantConditions return em.message() != null && !em.message().trim().isEmpty(); @@ -78,4 +164,4 @@ private boolean hasAnnotations(JsonRpcErrors errors) { private boolean isExceptionInstanceOfError(Throwable target, JsonRpcError em) { return em.exception().isInstance(target); } -} \ No newline at end of file +} diff --git a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java index ca249da4e5d..4f642e7b391 100644 --- a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java +++ b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java @@ -8,7 +8,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; -import com.googlecode.jsonrpc4j.HttpStatusCodeProvider; import com.googlecode.jsonrpc4j.JsonRpcInterceptor; import com.googlecode.jsonrpc4j.JsonRpcServer; import com.googlecode.jsonrpc4j.ProxyUtil; @@ -19,6 +18,8 @@ import java.util.Collections; import javax.servlet.ServletConfig; import javax.servlet.ServletException; +import javax.servlet.ServletResponse; +import javax.servlet.ServletResponseWrapper; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; @@ -27,7 +28,6 @@ import org.tron.common.parameter.CommonParameter; import org.tron.core.Constant; import org.tron.core.services.filter.BufferedResponseWrapper; -import org.tron.core.services.filter.CachedBodyRequestWrapper; import org.tron.core.services.http.RateLimiterServlet; @Component @@ -35,6 +35,7 @@ public class JsonRpcServlet extends RateLimiterServlet { private static final ObjectMapper MAPPER = buildMapper(); + private static final int MAX_RESPONSE_WRAPPER_DEPTH = 16; private static ObjectMapper buildMapper() { JsonFactory factory = JsonFactory.builder() @@ -68,6 +69,10 @@ private enum JsonRpcError { @Autowired private JsonRpcInterceptor interceptor; + void setRpcServer(JsonRpcServer rpcServer) { + this.rpcServer = rpcServer; + } + @Override public void init(ServletConfig config) throws ServletException { super.init(config); @@ -82,19 +87,6 @@ public void init(ServletConfig config) throws ServletException { rpcServer = new JsonRpcServer(compositeService); rpcServer.setErrorResolver(JsonRpcErrorResolver.INSTANCE); - HttpStatusCodeProvider httpStatusCodeProvider = new HttpStatusCodeProvider() { - @Override - public int getHttpStatusCode(int resultCode) { - return 200; - } - - @Override - public Integer getJsonRpcCode(int httpStatusCode) { - return null; - } - }; - rpcServer.setHttpStatusCodeProvider(httpStatusCodeProvider); - rpcServer.setShouldLogInvocationErrors(false); if (CommonParameter.getInstance().isMetricsPrometheusEnable()) { rpcServer.setInterceptorList(Collections.singletonList(interceptor)); @@ -103,6 +95,15 @@ public Integer getJsonRpcCode(int httpStatusCode) { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { + try { + doPostInternal(req, resp); + } catch (Error fatal) { + commitBareInternalServerError(resp); + throw fatal; + } + } + + private void doPostInternal(HttpServletRequest req, HttpServletResponse resp) throws IOException { CommonParameter parameter = CommonParameter.getInstance(); // Transport IOException from readBody propagates as HTTP 500 (genuine IO failure). @@ -133,6 +134,15 @@ protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws I writeJsonRpcError(resp, JsonRpcError.INVALID_REQUEST, "Invalid Request", null, false); return; } + if (!isBatch && hasInvalidRequestId(rootNode)) { + writeJsonRpcError(resp, JsonRpcError.INVALID_REQUEST, "Invalid Request", null, false); + return; + } + if (!isBatch && hasScalarParams(rootNode)) { + writeJsonRpcError(resp, JsonRpcError.INVALID_REQUEST, "Invalid Request", + rootNode.get("id"), false); + return; + } int batchSize = parameter.getJsonRpcMaxBatchSize(); if (isBatch && batchSize > 0 && rootNode.size() > batchSize) { writeJsonRpcError(resp, JsonRpcError.EXCEED_LIMIT, @@ -144,25 +154,35 @@ protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws I if (isBatch) { handleBatch(resp, rootNode, maxResponseSize); } else { - handleSingle(req, resp, rootNode, body, maxResponseSize); + handleSingle(resp, rootNode, body, maxResponseSize); } } - private void handleSingle(HttpServletRequest req, HttpServletResponse resp, - JsonNode rootNode, byte[] body, int maxResponseSize) throws IOException { - CachedBodyRequestWrapper cachedReq = new CachedBodyRequestWrapper(req, body); + private void handleSingle(HttpServletResponse resp, JsonNode rootNode, byte[] body, + int maxResponseSize) throws IOException { BufferedResponseWrapper bufferedResp = new BufferedResponseWrapper( resp, maxResponseSize); try { - rpcServer.handle(cachedReq, bufferedResp); - } catch (RuntimeException e) { + // JsonRpcServer.handle catches Throwable and would swallow fatal errors rethrown by the + // resolver. Use the lower-level entry point so single and batch requests share a boundary. + rpcServer.handleRequest(new ByteArrayInputStream(body), bufferedResp.getOutputStream()); + } catch (RuntimeException | IOException | Error e) { + rethrowIfFatal(e); logger.error("RPC execution failed", e); + if (!rootNode.has("id")) { + resp.setContentType("application/json-rpc"); + resp.setStatus(HttpServletResponse.SC_OK); + resp.setContentLength(0); + return; + } writeJsonRpcError(resp, JsonRpcError.INTERNAL_ERROR, "Internal error", rootNode.get("id"), false); return; } + bufferedResp.setContentType("application/json-rpc"); + bufferedResp.setStatus(HttpServletResponse.SC_OK); bufferedResp.commitToResponse(); if (bufferedResp.isOverflow()) { writeJsonRpcError(resp, JsonRpcError.RESPONSE_TOO_LARGE, @@ -177,14 +197,30 @@ private void handleBatch(HttpServletResponse resp, JsonNode rootNode, int maxRes ArrayNode batchResult = MAPPER.createArrayNode(); int accumulatedSize = 2; // "[]" boolean overflow = false; + BatchFailureLog failureLog = new BatchFailureLog(); for (int i = 0; i < rootNode.size(); i++) { JsonNode subRequest = rootNode.get(i); + if (!subRequest.isObject() || hasInvalidRequestId(subRequest) + || hasScalarParams(subRequest)) { + ObjectNode errNode = buildErrorNode(JsonRpcError.INVALID_REQUEST, "Invalid Request", + subRequest.get("id")); + if (!overflow) { + byte[] errBytes = MAPPER.writeValueAsBytes(errNode); + int addition = errBytes.length + (!batchResult.isEmpty() ? 1 : 0); + if (maxResponseSize > 0 && accumulatedSize + addition > maxResponseSize) { + overflow = true; + } else { + accumulatedSize += addition; + } + } + batchResult.add(errNode); + continue; + } + if (overflow) { - if (!subRequest.isObject()) { - batchResult.add(buildErrorNode(JsonRpcError.INVALID_REQUEST, "Invalid Request", null)); - } else if (subRequest.has("id")) { + if (subRequest.has("id")) { // Notifications (no "id") do not get a response even on overflow. batchResult.add(buildErrorNode(JsonRpcError.RESPONSE_TOO_LARGE, "Response exceeds the limit of " + maxResponseSize + " bytes", @@ -193,43 +229,14 @@ private void handleBatch(HttpServletResponse resp, JsonNode rootNode, int maxRes continue; } - if (!subRequest.isObject()) { - ObjectNode errNode = buildErrorNode(JsonRpcError.INVALID_REQUEST, "Invalid Request", null); - byte[] errBytes = MAPPER.writeValueAsBytes(errNode); - int addition = errBytes.length + (!batchResult.isEmpty() ? 1 : 0); - if (maxResponseSize > 0 && accumulatedSize + addition > maxResponseSize) { - overflow = true; - } else { - accumulatedSize += addition; - } - batchResult.add(errNode); - continue; - } - - byte[] subBody; - try { - subBody = MAPPER.writeValueAsBytes(subRequest); - } catch (JsonProcessingException e) { - writeJsonRpcError(resp, JsonRpcError.INTERNAL_ERROR, "Internal error", null, true); - return; - } - - ByteArrayOutputStream subOutput = new ByteArrayOutputStream(); - try { - rpcServer.handleRequest(new ByteArrayInputStream(subBody), subOutput); - } catch (RuntimeException e) { - logger.error("RPC execution failed for batch sub-request {}", i, e); - writeJsonRpcError(resp, JsonRpcError.INTERNAL_ERROR, "Internal error", null, true); - return; - } - - byte[] responseBytes = subOutput.toByteArray(); + byte[] responseBytes = executeBatchRequest(subRequest, i, failureLog); if (responseBytes.length == 0) { continue; // notification — no response } // comma(,) separator between array elements int addition = responseBytes.length + (!batchResult.isEmpty() ? 1 : 0); + // Reject bytes beyond the remaining budget before parsing, even if they are malformed. if (maxResponseSize > 0 && accumulatedSize + addition > maxResponseSize) { overflow = true; batchResult.add(buildErrorNode(JsonRpcError.RESPONSE_TOO_LARGE, @@ -237,15 +244,24 @@ private void handleBatch(HttpServletResponse resp, JsonNode rootNode, int maxRes subRequest.get("id"))); continue; } - accumulatedSize += addition; - JsonNode responseNode; try { responseNode = MAPPER.readTree(responseBytes); } catch (IOException e) { - writeJsonRpcError(resp, JsonRpcError.INTERNAL_ERROR, "Internal error", null, true); - return; + responseBytes = internalErrorResponse(subRequest.get("id")); + responseNode = MAPPER.readTree(responseBytes); + // Charge only the replacement, not the discarded malformed response bytes. + addition = responseBytes.length + (!batchResult.isEmpty() ? 1 : 0); + // A replacement error can be larger than the malformed response it replaces. + if (maxResponseSize > 0 && accumulatedSize + addition > maxResponseSize) { + overflow = true; + batchResult.add(buildErrorNode(JsonRpcError.RESPONSE_TOO_LARGE, + "Response exceeds the limit of " + maxResponseSize + " bytes", + subRequest.get("id"))); + continue; + } } + accumulatedSize += addition; batchResult.add(responseNode); } @@ -265,6 +281,82 @@ private void handleBatch(HttpServletResponse resp, JsonNode rootNode, int maxRes resp.getOutputStream().flush(); } + private byte[] executeBatchRequest(JsonNode subRequest, int index, BatchFailureLog failureLog) + throws IOException { + byte[] subBody; + try { + subBody = MAPPER.writeValueAsBytes(subRequest); + } catch (JsonProcessingException e) { + return internalErrorResponse(subRequest.get("id")); + } + + ByteArrayOutputStream subOutput = new ByteArrayOutputStream(); + try { + rpcServer.handleRequest(new ByteArrayInputStream(subBody), subOutput); + } catch (RuntimeException | IOException | Error e) { + rethrowIfFatal(e); + failureLog.record(index, e); + return internalErrorResponse(subRequest.get("id")); + } + return subOutput.toByteArray(); + } + + private static class BatchFailureLog { + private boolean logged; + + private void record(int index, Throwable failure) { + if (!logged) { + logged = true; + logger.error("RPC execution failed for batch sub-request {}", index, failure); + } else { + logger.debug("RPC execution failed for batch sub-request {} ({})", + index, failure.getClass().getName()); + } + } + } + + private byte[] internalErrorResponse(JsonNode id) throws JsonProcessingException { + return MAPPER.writeValueAsBytes( + buildErrorNode(JsonRpcError.INTERNAL_ERROR, "Internal error", id)); + } + + private static void rethrowIfFatal(Throwable exception) { + Error fatal = JsonRpcErrorResolver.findFatalCause(exception); + if (fatal != null) { + throw fatal; + } + } + + private static void commitBareInternalServerError(HttpServletResponse resp) { + try { + // Response decorators such as CharResponseWrapper do not propagate flushBuffer. + // Bound this allocation-free walk and never delegate cleanup through an unresolved wrapper. + HttpServletResponse target = resp; + int depth = 0; + while (target instanceof ServletResponseWrapper) { + if (depth++ >= MAX_RESPONSE_WRAPPER_DEPTH) { + return; + } + ServletResponse inner = ((ServletResponseWrapper) target).getResponse(); + if (inner == target || !(inner instanceof HttpServletResponse)) { + return; + } + target = (HttpServletResponse) inner; + } + if (target.isCommitted()) { + return; + } + // Best effort: commit an empty response before rethrowing the Error so the container does + // not render its default error page, which may expose Throwable details. + target.resetBuffer(); + target.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + target.setContentLength(0); + target.flushBuffer(); + } catch (Throwable ignored) { + // Cleanup must never replace the original fatal Error. + } + } + private byte[] readBody(InputStream in) throws IOException { ByteArrayOutputStream buffer = new ByteArrayOutputStream(); byte[] tmp = new byte[4096]; @@ -281,7 +373,7 @@ private ObjectNode buildErrorNode(JsonRpcError error, String message, JsonNode i ObjectNode errNode = errorObj.putObject("error"); errNode.put("code", error.code); errNode.put("message", message); - if (id != null && !id.isNull() && !id.isMissingNode()) { + if (isValidRequestId(id) && !id.isNull()) { errorObj.set("id", id); } else { errorObj.putNull("id"); @@ -289,6 +381,19 @@ private ObjectNode buildErrorNode(JsonRpcError error, String message, JsonNode i return errorObj; } + private static boolean hasInvalidRequestId(JsonNode request) { + return request.has("id") && !isValidRequestId(request.get("id")); + } + + private static boolean hasScalarParams(JsonNode request) { + // Explicit null keeps the existing jsonrpc4j dispatch semantics. + return request.hasNonNull("params") && !request.get("params").isContainerNode(); + } + + private static boolean isValidRequestId(JsonNode id) { + return id != null && (id.isNull() || id.isTextual() || id.isNumber()); + } + private void writeJsonRpcError(HttpServletResponse resp, JsonRpcError error, String message, JsonNode id, boolean isBatch) throws IOException { ObjectNode errorObj = buildErrorNode(error, message, id); diff --git a/framework/src/main/java/org/tron/core/services/jsonrpc/TronJsonRpc.java b/framework/src/main/java/org/tron/core/services/jsonrpc/TronJsonRpc.java index 50da763b8b9..0ca897c0eb7 100644 --- a/framework/src/main/java/org/tron/core/services/jsonrpc/TronJsonRpc.java +++ b/framework/src/main/java/org/tron/core/services/jsonrpc/TronJsonRpc.java @@ -73,9 +73,17 @@ BlockResult ethGetBlockByNumber(String bnOrId, Boolean fullTransactionObjects) throws JsonRpcInvalidParamsException; @JsonRpcMethod("net_version") + @JsonRpcErrors({ + @JsonRpcError(exception = JsonRpcInternalException.class, code = -32001, + message = "Chain identity unavailable", data = "{}"), + }) String getNetVersion() throws JsonRpcInternalException; @JsonRpcMethod("eth_chainId") + @JsonRpcErrors({ + @JsonRpcError(exception = JsonRpcInternalException.class, code = -32001, + message = "Chain identity unavailable", data = "{}"), + }) String ethChainId() throws JsonRpcInternalException; @JsonRpcMethod("net_listening") @@ -328,8 +336,10 @@ Object[] getFilterChanges(String filterId) @JsonRpcError(exception = JsonRpcMethodNotFoundException.class, code = -32601, data = "{}"), @JsonRpcError(exception = JsonRpcTooManyResultException.class, code = -32005, data = "{}"), @JsonRpcError(exception = BadItemException.class, code = -32000, data = "{}"), - @JsonRpcError(exception = ExecutionException.class, code = -32000, data = "{}"), - @JsonRpcError(exception = InterruptedException.class, code = -32000, data = "{}"), + @JsonRpcError(exception = ExecutionException.class, code = -32000, + message = "Internal error", data = "{}"), + @JsonRpcError(exception = InterruptedException.class, code = -32000, + message = "Internal error", data = "{}"), @JsonRpcError(exception = ItemNotFoundException.class, code = -32000, data = "{}"), }) LogFilterElement[] getLogs(FilterRequest fr) throws JsonRpcInvalidParamsException, @@ -342,8 +352,10 @@ LogFilterElement[] getLogs(FilterRequest fr) throws JsonRpcInvalidParamsExceptio @JsonRpcError(exception = JsonRpcMethodNotFoundException.class, code = -32601, data = "{}"), @JsonRpcError(exception = JsonRpcTooManyResultException.class, code = -32005, data = "{}"), @JsonRpcError(exception = BadItemException.class, code = -32000, data = "{}"), - @JsonRpcError(exception = ExecutionException.class, code = -32000, data = "{}"), - @JsonRpcError(exception = InterruptedException.class, code = -32000, data = "{}"), + @JsonRpcError(exception = ExecutionException.class, code = -32000, + message = "Internal error", data = "{}"), + @JsonRpcError(exception = InterruptedException.class, code = -32000, + message = "Internal error", data = "{}"), @JsonRpcError(exception = ItemNotFoundException.class, code = -32000, data = "{}"), }) LogFilterElement[] getFilterLogs(String filterId) throws JsonRpcInvalidParamsException, diff --git a/framework/src/main/java/org/tron/core/services/jsonrpc/TronJsonRpcImpl.java b/framework/src/main/java/org/tron/core/services/jsonrpc/TronJsonRpcImpl.java index 6be47886117..4086507c0fb 100644 --- a/framework/src/main/java/org/tron/core/services/jsonrpc/TronJsonRpcImpl.java +++ b/framework/src/main/java/org/tron/core/services/jsonrpc/TronJsonRpcImpl.java @@ -36,6 +36,7 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.ForkJoinPool; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.regex.Pattern; import lombok.Getter; import lombok.extern.slf4j.Slf4j; @@ -193,6 +194,7 @@ public enum RequestSource { private final ExecutorService sectionExecutor; private final NodeInfoService nodeInfoService; private final Wallet wallet; + private final AtomicBoolean chainIdentityLookupFailed = new AtomicBoolean(); @Autowired private Manager manager; private final String esName = "query-section"; @@ -424,9 +426,21 @@ public String ethChainId() throws JsonRpcInternalException { // return hash of genesis block try { byte[] chainId = wallet.getBlockCapsuleByNum(0).getBlockId().getBytes(); - return ByteArray.toJsonHex(Arrays.copyOfRange(chainId, chainId.length - 4, chainId.length)); + String result = ByteArray.toJsonHex( + Arrays.copyOfRange(chainId, chainId.length - 4, chainId.length)); + if (chainIdentityLookupFailed.compareAndSet(true, false)) { + logger.info("Chain identity lookup recovered"); + } + return result; } catch (Exception e) { - throw new JsonRpcInternalException(e.getMessage()); + // Mapped errors bypass the resolver's unhandled-exception log, so record the complete + // cause once per failure episode at the lookup boundary. + if (chainIdentityLookupFailed.compareAndSet(false, true)) { + logger.warn("Chain identity lookup failed", e); + } + // Keep the cause so the resolver can still find a fatal error wrapped in an exception, + // and carry the public message on the exception itself rather than the underlying one. + throw new JsonRpcInternalException("Chain identity unavailable", e); } } diff --git a/framework/src/main/java/org/tron/core/services/jsonrpc/filters/LogBlockQuery.java b/framework/src/main/java/org/tron/core/services/jsonrpc/filters/LogBlockQuery.java index 2daf2ca3271..4cb0d7039bc 100644 --- a/framework/src/main/java/org/tron/core/services/jsonrpc/filters/LogBlockQuery.java +++ b/framework/src/main/java/org/tron/core/services/jsonrpc/filters/LogBlockQuery.java @@ -158,7 +158,17 @@ private BitSet partialMatch(final int[][] bitIndexes, int section) // 3. Wait for all results and cache them Map resultCache = new HashMap<>(); for (Map.Entry> entry : bitIndexResults.entrySet()) { - BitSet result = entry.getValue().get(); + BitSet result; + try { + result = entry.getValue().get(); + } catch (ExecutionException e) { + logger.warn("JSON-RPC log query failed", e.getCause()); + throw e; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.warn("JSON-RPC log query interrupted", e); + throw e; + } if (result != null) { resultCache.put(entry.getKey(), result); } diff --git a/framework/src/test/java/org/tron/core/services/filter/CachedBodyRequestWrapperTest.java b/framework/src/test/java/org/tron/core/services/filter/CachedBodyRequestWrapperTest.java deleted file mode 100644 index 813b1a61bea..00000000000 --- a/framework/src/test/java/org/tron/core/services/filter/CachedBodyRequestWrapperTest.java +++ /dev/null @@ -1,109 +0,0 @@ -package org.tron.core.services.filter; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import org.junit.Test; -import org.springframework.mock.web.MockHttpServletRequest; - -public class CachedBodyRequestWrapperTest { - - private static final byte[] BODY = "hello world".getBytes(StandardCharsets.UTF_8); - - private static byte[] readFully(javax.servlet.ServletInputStream in) throws IOException { - ByteArrayOutputStream out = new ByteArrayOutputStream(); - byte[] buf = new byte[128]; - int n; - while ((n = in.read(buf)) != -1) { - out.write(buf, 0, n); - } - return out.toByteArray(); - } - - // --- getInputStream --- - - @Test - public void getInputStream_returnsBodyContent() throws IOException { - CachedBodyRequestWrapper w = new CachedBodyRequestWrapper(new MockHttpServletRequest(), BODY); - byte[] read = readFully(w.getInputStream()); - assertEquals(new String(BODY, StandardCharsets.UTF_8), - new String(read, StandardCharsets.UTF_8)); - } - - @Test - public void getInputStream_calledTwice_bothSucceed() throws IOException { - CachedBodyRequestWrapper w = new CachedBodyRequestWrapper(new MockHttpServletRequest(), BODY); - w.getInputStream(); - // second call of the same accessor is allowed by the servlet spec - w.getInputStream(); - } - - // --- getReader --- - - @Test - public void getReader_returnsBodyContent() throws IOException { - CachedBodyRequestWrapper w = new CachedBodyRequestWrapper(new MockHttpServletRequest(), BODY); - String line = w.getReader().readLine(); - assertEquals("hello world", line); - } - - @Test - public void getReader_calledTwice_bothSucceed() throws IOException { - CachedBodyRequestWrapper w = new CachedBodyRequestWrapper(new MockHttpServletRequest(), BODY); - w.getReader(); - w.getReader(); - } - - // --- mutual exclusion --- - - @Test(expected = IllegalStateException.class) - public void getReader_afterGetInputStream_throws() throws IOException { - CachedBodyRequestWrapper w = new CachedBodyRequestWrapper(new MockHttpServletRequest(), BODY); - w.getInputStream(); - w.getReader(); - } - - @Test(expected = IllegalStateException.class) - public void getInputStream_afterGetReader_throws() throws IOException { - CachedBodyRequestWrapper w = new CachedBodyRequestWrapper(new MockHttpServletRequest(), BODY); - w.getReader(); - w.getInputStream(); - } - - // --- stream contract --- - - @Test - public void getInputStream_isFinished_afterFullRead() throws IOException { - CachedBodyRequestWrapper w = new CachedBodyRequestWrapper(new MockHttpServletRequest(), BODY); - javax.servlet.ServletInputStream in = w.getInputStream(); - while (in.read() != -1) { - // drain - } - assertTrue(in.isFinished()); - } - - @Test - public void getInputStream_isReady_returnsTrue() { - CachedBodyRequestWrapper w = new CachedBodyRequestWrapper(new MockHttpServletRequest(), BODY); - assertTrue(w.getInputStream().isReady()); - } - - @Test - public void getInputStream_emptyBody_isFinishedImmediately() { - CachedBodyRequestWrapper w = new CachedBodyRequestWrapper(new MockHttpServletRequest(), - new byte[0]); - assertTrue(w.getInputStream().isFinished()); - } - - @Test - public void getReader_usesRequestCharacterEncoding() throws IOException { - MockHttpServletRequest req = new MockHttpServletRequest(); - req.setCharacterEncoding("UTF-8"); - byte[] utf8Body = "tron".getBytes(StandardCharsets.UTF_8); - CachedBodyRequestWrapper w = new CachedBodyRequestWrapper(req, utf8Body); - assertEquals("tron", w.getReader().readLine()); - } -} diff --git a/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcDispatchContractTest.java b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcDispatchContractTest.java new file mode 100644 index 00000000000..cd37945f85e --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcDispatchContractTest.java @@ -0,0 +1,209 @@ +package org.tron.core.services.jsonrpc; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.googlecode.jsonrpc4j.JsonRpcBasicServer; +import com.googlecode.jsonrpc4j.JsonRpcMethod; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import org.junit.Assert; +import org.junit.Test; + +/** + * Characterizes the jsonrpc4j dispatch behavior relied on by the JSON-RPC implementation. + */ +public class JsonRpcDispatchContractTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final JsonRpcBasicServer server = + new JsonRpcBasicServer(new DispatchServiceImpl(), DispatchService.class); + + @Test + public void testNullArrayElementReachesMethodBody() throws Exception { + JsonNode response = handle(request("oneArg", "[null]")); + + Assert.assertEquals("one:null", response.get("result").asText()); + Assert.assertEquals(7, response.get("id").asInt()); + } + + @Test + public void testMissingAndEmptyPositionalParametersFailArityValidation() throws Exception { + assertInvalidParams(handle("{\"jsonrpc\":\"2.0\",\"method\":\"oneArg\",\"id\":7}")); + assertInvalidParams(handle(request("oneArg", "null"))); + assertInvalidParams(handle(request("oneArg", "[]"))); + assertInvalidParams(handle(request("twoArg", "[\"a\"]"))); + assertInvalidParams(handle(request("twoArg", "[\"a\",\"b\",\"c\"]"))); + } + + @Test + public void testObjectParametersDependOnMethodArity() throws Exception { + assertInvalidParams(handle(request("oneArg", "{}"))); + + JsonNode response = handle(request("noArg", "{}")); + Assert.assertEquals("noArg", response.get("result").asText()); + + assertInvalidParams(handle(request("noArg", "{\"a\":1}"))); + } + + @Test + public void testScalarParametersEscapeAsKnownFrameworkDefect() { + Assert.assertThrows(IllegalArgumentException.class, + () -> handle(request("oneArg", "5"))); + } + + @Test + public void testStringParameterConversionContract() throws Exception { + JsonNode objectResponse = handle(request("callStr", "[\"args\",{\"blockNumber\":\"0x1\"}]")); + assertParseErrorWithLostId(objectResponse); + + JsonNode arrayResponse = handle(request("callStr", "[\"args\",[1,2]]")); + assertParseErrorWithLostId(arrayResponse); + + JsonNode numberResponse = handle(request("callStr", "[\"args\",123]")); + Assert.assertEquals("str:123", numberResponse.get("result").asText()); + Assert.assertEquals(7, numberResponse.get("id").asInt()); + } + + @Test + public void testObjectParameterPreservesRuntimeShape() throws Exception { + JsonNode objectResponse = handle(request( + "callObj", "[\"args\",{\"blockNumber\":\"0x1\"}]")); + Assert.assertEquals("obj:LinkedHashMap:0x1", objectResponse.get("result").asText()); + + JsonNode nullResponse = handle(request("callObj", "[\"args\",null]")); + Assert.assertEquals("obj:null", nullResponse.get("result").asText()); + + JsonNode stringResponse = handle(request("callObj", "[\"args\",\"latest\"]")); + Assert.assertEquals("obj:String:latest", stringResponse.get("result").asText()); + } + + @Test + public void testOverloadSelectionUsesParameterCount() throws Exception { + Assert.assertEquals("over1:a", + handle(request("over", "[\"a\"]")).get("result").asText()); + Assert.assertEquals("over2:a,b", + handle(request("over", "[\"a\",\"b\"]")).get("result").asText()); + Assert.assertEquals("over2:a,null", + handle(request("over", "[\"a\",null]")).get("result").asText()); + Assert.assertEquals("over1:null", + handle(request("over", "[null]")).get("result").asText()); + assertInvalidParams(handle(request("over", "[]"))); + assertInvalidParams(handle("{\"jsonrpc\":\"2.0\",\"method\":\"over\",\"id\":7}")); + } + + @Test + public void testNativeBatchIsolatesScalarParameterDefect() throws Exception { + JsonNode response = handle("[" + + request("oneArg", "5") + "," + + requestWithId("oneArg", "[\"ok\"]", 8) + "]"); + + Assert.assertTrue(response.isArray()); + Assert.assertEquals(2, response.size()); + assertParseErrorWithLostId(response.get(0)); + Assert.assertEquals("one:ok", response.get(1).get("result").asText()); + Assert.assertEquals(8, response.get(1).get("id").asInt()); + } + + private JsonNode handle(String json) throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + server.handleRequest( + new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)), output); + return MAPPER.readTree(output.toByteArray()); + } + + private static String request(String method, String params) { + return requestWithId(method, params, 7); + } + + private static String requestWithId(String method, String params, int id) { + return "{\"jsonrpc\":\"2.0\",\"method\":\"" + method + + "\",\"params\":" + params + ",\"id\":" + id + "}"; + } + + private static void assertInvalidParams(JsonNode response) { + JsonNode error = response.get("error"); + Assert.assertEquals(-32602, error.get("code").asInt()); + Assert.assertEquals("method parameters invalid", error.get("message").asText()); + Assert.assertEquals(7, response.get("id").asInt()); + } + + private static void assertParseErrorWithLostId(JsonNode response) { + JsonNode error = response.get("error"); + Assert.assertEquals(-32700, error.get("code").asInt()); + Assert.assertEquals("JSON parse error", error.get("message").asText()); + Assert.assertTrue(response.get("id").isTextual()); + Assert.assertEquals("null", response.get("id").asText()); + } + + public interface DispatchService { + + @JsonRpcMethod("noArg") + String noArg(); + + @JsonRpcMethod("oneArg") + String oneArg(String value); + + @JsonRpcMethod("twoArg") + String twoArg(String first, String second); + + @JsonRpcMethod("over") + String over(String value); + + @JsonRpcMethod("over") + String over(String first, String second); + + @JsonRpcMethod("callObj") + String callObj(Object arguments, Object block); + + @JsonRpcMethod("callStr") + String callStr(Object arguments, String block); + } + + public static class DispatchServiceImpl implements DispatchService { + + @Override + public String noArg() { + return "noArg"; + } + + @Override + public String oneArg(String value) { + return "one:" + value; + } + + @Override + public String twoArg(String first, String second) { + return "two:" + first + "," + second; + } + + @Override + public String over(String value) { + return "over1:" + value; + } + + @Override + public String over(String first, String second) { + return "over2:" + first + "," + second; + } + + @Override + public String callObj(Object arguments, Object block) { + if (block == null) { + return "obj:null"; + } + if (block instanceof Map) { + return "obj:" + block.getClass().getSimpleName() + ":" + + ((Map) block).get("blockNumber"); + } + return "obj:" + block.getClass().getSimpleName() + ":" + block; + } + + @Override + public String callStr(Object arguments, String block) { + return "str:" + block; + } + } +} diff --git a/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcErrorResolverTest.java b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcErrorResolverTest.java index d8e64308ab8..9237095300d 100644 --- a/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcErrorResolverTest.java +++ b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcErrorResolverTest.java @@ -1,15 +1,24 @@ package org.tron.core.services.jsonrpc; +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.IThrowableProxy; +import ch.qos.logback.core.read.ListAppender; import com.fasterxml.jackson.databind.JsonNode; -import com.googlecode.jsonrpc4j.ErrorData; import com.googlecode.jsonrpc4j.ErrorResolver.JsonError; import com.googlecode.jsonrpc4j.JsonRpcError; import com.googlecode.jsonrpc4j.JsonRpcErrors; +import com.googlecode.jsonrpc4j.JsonRpcMethod; import java.lang.reflect.Method; -import java.util.ArrayList; +import java.util.Collections; import java.util.List; +import java.util.concurrent.ExecutionException; import org.junit.Assert; +import org.junit.Before; import org.junit.Test; +import org.slf4j.LoggerFactory; +import org.tron.core.exception.TronError; import org.tron.core.exception.jsonrpc.JsonRpcException; import org.tron.core.exception.jsonrpc.JsonRpcInternalException; import org.tron.core.exception.jsonrpc.JsonRpcInvalidParamsException; @@ -17,27 +26,51 @@ public class JsonRpcErrorResolverTest { + private static final List NO_ARGUMENTS = Collections.emptyList(); + private final JsonRpcErrorResolver resolver = JsonRpcErrorResolver.INSTANCE; @JsonRpcErrors({ @JsonRpcError(exception = JsonRpcInvalidRequestException.class, code = -32600, data = "{}"), @JsonRpcError(exception = JsonRpcInvalidParamsException.class, code = -32602, data = "{}"), @JsonRpcError(exception = JsonRpcInternalException.class, code = -32000, data = "{}"), + @JsonRpcError(exception = ExecutionException.class, code = -32000, data = "{}"), @JsonRpcError(exception = JsonRpcException.class, code = -1) - }) + }) public void dummyMethod() { } - @Test - public void testResolveErrorWithTronException() throws Exception { + @JsonRpcErrors({ + @JsonRpcError(exception = IllegalArgumentException.class, code = -32602, + message = "annotation message"), + @JsonRpcError(exception = NullPointerException.class, code = -32602), + @JsonRpcError(exception = IllegalStateException.class, code = -32600), + @JsonRpcError(exception = UnsupportedOperationException.class, code = -32601), + @JsonRpcError(exception = RuntimeException.class, code = -32000) + }) + public void messageMethod() { + } - String message = "JsonRpcInvalidRequestException"; + @JsonRpcMethod("test_unmapped") + public void unmappedMethod() { + } + @JsonRpcMethod("test_unmapped_other") + public void otherUnmappedMethod() { + } + + @Before + public void clearSeenFailures() { + JsonRpcErrorResolver.clearSeenFailuresForTest(); + } + + @Test + public void testMappedErrorsPreserveCodeAndDataPriority() throws Exception { + String message = "JsonRpcInvalidRequestException"; JsonRpcException exception = new JsonRpcInvalidRequestException(message); - Method method = this.getClass().getMethod("dummyMethod"); - List arguments = new ArrayList<>(); + Method method = getClass().getMethod("dummyMethod"); - JsonError error = resolver.resolveError(exception, method, arguments); + JsonError error = resolver.resolveError(exception, method, NO_ARGUMENTS); Assert.assertNotNull(error); Assert.assertEquals(-32600, error.code); Assert.assertEquals(message, error.message); @@ -46,7 +79,7 @@ public void testResolveErrorWithTronException() throws Exception { message = "JsonRpcInternalException"; String data = "JsonRpcInternalException data"; exception = new JsonRpcInternalException(message, data); - error = resolver.resolveError(exception, method, arguments); + error = resolver.resolveError(exception, method, NO_ARGUMENTS); Assert.assertNotNull(error); Assert.assertEquals(-32000, error.code); @@ -54,7 +87,7 @@ public void testResolveErrorWithTronException() throws Exception { Assert.assertEquals(data, error.data); exception = new JsonRpcInternalException(message, null); - error = resolver.resolveError(exception, method, arguments); + error = resolver.resolveError(exception, method, NO_ARGUMENTS); Assert.assertNotNull(error); Assert.assertEquals(-32000, error.code); @@ -63,13 +96,251 @@ public void testResolveErrorWithTronException() throws Exception { message = "JsonRpcException"; exception = new JsonRpcException(message, null); - error = resolver.resolveError(exception, method, arguments); + error = resolver.resolveError(exception, method, NO_ARGUMENTS); Assert.assertNotNull(error); Assert.assertEquals(-1, error.code); Assert.assertEquals(message, error.message); - Assert.assertTrue(error.data instanceof ErrorData); + Assert.assertNull(error.data); + } + + @Test + public void testUnmappedExceptionsUseSanitizedInternalError() throws Exception { + Method method = getClass().getMethod("unmappedMethod"); + JsonError error = resolver.resolveError( + new RuntimeException("sensitive-marker"), method, NO_ARGUMENTS); + + assertInternalError(error); + } + + @Test + public void testUnmappedExceptionLoggingIsBoundedByMethodAndType() throws Exception { + Logger apiLogger = (Logger) LoggerFactory.getLogger("API"); + Level originalLevel = apiLogger.getLevel(); + ListAppender appender = new ListAppender<>(); + appender.start(); + apiLogger.addAppender(appender); + apiLogger.setLevel(Level.DEBUG); + + try { + Method method = getClass().getMethod("unmappedMethod"); + resolver.resolveError(new RuntimeException("first-sensitive-marker"), method, NO_ARGUMENTS); + resolver.resolveError(new RuntimeException("second-sensitive-marker"), method, NO_ARGUMENTS); + resolver.resolveError(new IllegalStateException("third-sensitive-marker"), method, + NO_ARGUMENTS); + resolver.resolveError(new RuntimeException("fourth-sensitive-marker"), + getClass().getMethod("otherUnmappedMethod"), NO_ARGUMENTS); + + Assert.assertEquals(3, countEvents(appender, Level.WARN)); + Assert.assertEquals(1, countEvents(appender, Level.DEBUG)); + + ILoggingEvent firstWarning = findEvent(appender, Level.WARN, "test_unmapped"); + Assert.assertNotNull(firstWarning); + IThrowableProxy throwable = firstWarning.getThrowableProxy(); + Assert.assertNotNull(throwable); + Assert.assertEquals(RuntimeException.class.getName(), throwable.getClassName()); + Assert.assertEquals("first-sensitive-marker", throwable.getMessage()); + + ILoggingEvent repeated = findEvent(appender, Level.DEBUG, "test_unmapped"); + Assert.assertNotNull(repeated); + Assert.assertNull(repeated.getThrowableProxy()); + Assert.assertFalse(repeated.getFormattedMessage().contains("second-sensitive-marker")); + } finally { + apiLogger.setLevel(originalLevel); + apiLogger.detachAppender(appender); + appender.stop(); + } + } + + @Test + public void testNullMethodUsesSanitizedInternalError() { + JsonError error = resolver.resolveError( + new RuntimeException("sensitive-marker"), null, NO_ARGUMENTS); + + assertInternalError(error); + } + + @Test + public void testMappedMessagePriorityAndDefaults() throws Exception { + Method method = getClass().getMethod("messageMethod"); + + JsonError error = resolver.resolveError( + new IllegalArgumentException("exception message"), method, NO_ARGUMENTS); + Assert.assertEquals("annotation message", error.message); + + error = resolver.resolveError( + new RuntimeException("filter not found"), method, NO_ARGUMENTS); + Assert.assertEquals("filter not found", error.message); + + error = resolver.resolveError(new NullPointerException(), method, NO_ARGUMENTS); + Assert.assertEquals("Invalid params", error.message); + + error = resolver.resolveError(new IllegalStateException(" "), method, NO_ARGUMENTS); + Assert.assertEquals("Invalid Request", error.message); + + error = resolver.resolveError(new UnsupportedOperationException(), method, NO_ARGUMENTS); + Assert.assertEquals("Method not found", error.message); + + error = resolver.resolveError(new RuntimeException(), method, NO_ARGUMENTS); + Assert.assertEquals("Internal error", error.message); + } + + @Test + public void testNonFatalErrorUsesSanitizedInternalError() throws Exception { + Method method = getClass().getMethod("unmappedMethod"); + JsonError error = resolver.resolveError(new AssertionError("sensitive-marker"), + method, NO_ARGUMENTS); + + assertInternalError(error); + } + + @Test + public void testFatalErrorsPropagate() throws Exception { + Method method = getClass().getMethod("unmappedMethod"); + + assertFatalPropagates(new StackOverflowError("fatal-marker"), method); + assertFatalPropagates(new ThreadDeath(), method); + assertFatalPropagates(new LinkageError("fatal-marker"), method); + assertFatalPropagates( + new TronError("fatal-marker", TronError.ErrCode.API_SERVER_INIT), method); + } + + @Test + public void testWrappedFatalErrorPropagatesActualCause() throws Exception { + Method method = getClass().getMethod("dummyMethod"); + StackOverflowError fatal = new StackOverflowError("fatal-marker"); + + Error thrown = Assert.assertThrows(Error.class, + () -> resolver.resolveError(new ExecutionException(fatal), method, NO_ARGUMENTS)); + + Assert.assertSame(fatal, thrown); + } + + @Test(timeout = 5000) + public void testCyclicCauseChainTerminates() throws Exception { + Method method = getClass().getMethod("unmappedMethod"); + CyclicException first = new CyclicException("first"); + CyclicException second = new CyclicException("second"); + first.setNext(second); + second.setNext(first); + + JsonError error = resolver.resolveError(first, method, NO_ARGUMENTS); + + assertInternalError(error); + } + + @Test(timeout = 5000) + public void testFindFatalCauseWithNull() { + Assert.assertNull(JsonRpcErrorResolver.findFatalCause(null)); + } + + @Test(timeout = 5000) + public void testFindFatalCauseWithOrdinaryChain() { + Throwable chain = new Exception("outer", new Exception("middle", new Exception("inner"))); + + Assert.assertNull(JsonRpcErrorResolver.findFatalCause(chain)); + } + + @Test(timeout = 5000) + public void testFindFatalCauseAtEndOfChain() { + StackOverflowError fatal = new StackOverflowError("fatal-marker"); + Throwable chain = new Exception("outer", new Exception("middle", fatal)); + + Assert.assertSame(fatal, JsonRpcErrorResolver.findFatalCause(chain)); + } + + @Test(timeout = 5000) + public void testFindFatalCauseWithWrappedTronError() { + TronError fatal = new TronError("fatal-marker", TronError.ErrCode.API_SERVER_INIT); + + Assert.assertSame(fatal, JsonRpcErrorResolver.findFatalCause(new Exception("outer", fatal))); + } + + @Test(timeout = 5000) + public void testFindFatalCauseWithOrdinaryCycle() { + Exception first = new Exception("first"); + Exception second = new Exception("second"); + first.initCause(second); + second.initCause(first); + + Assert.assertNull(JsonRpcErrorResolver.findFatalCause(first)); + } + + @Test(timeout = 5000) + public void testFindFatalCauseInsideCycleAfterPrefix() { + StackOverflowError fatal = new StackOverflowError("fatal-marker"); + Throwable[] chain = new Throwable[12]; + for (int i = 0; i < chain.length; i++) { + chain[i] = i == 8 ? fatal : new Exception("cause-" + i); + } + for (int i = 0; i < chain.length - 1; i++) { + chain[i].initCause(chain[i + 1]); + } + chain[11].initCause(chain[6]); + + Assert.assertSame(fatal, JsonRpcErrorResolver.findFatalCause(chain[0])); + } + + @Test(timeout = 5000) + public void testFindFatalCauseBeyondWrapperDepthLimit() { + StackOverflowError fatal = new StackOverflowError("fatal-marker"); + Throwable chain = fatal; + for (int i = 0; i < 64; i++) { + chain = new Exception("cause-" + i, chain); + } + + Assert.assertSame(fatal, JsonRpcErrorResolver.findFatalCause(chain)); + } + + private void assertFatalPropagates(Error fatal, Method method) { + Error thrown = Assert.assertThrows(Error.class, + () -> resolver.resolveError(fatal, method, NO_ARGUMENTS)); + Assert.assertSame(fatal, thrown); + } + + private static void assertInternalError(JsonError error) { + Assert.assertNotNull(error); + Assert.assertEquals(-32603, error.code); + Assert.assertEquals("Internal error", error.message); + Assert.assertNull(error.data); + } + + private static ILoggingEvent findEvent(ListAppender appender, Level level, + String marker) { + for (ILoggingEvent event : appender.list) { + if (event.getLevel() == level && event.getFormattedMessage().contains(marker)) { + return event; + } + } + return null; + } + + private static int countEvents(ListAppender appender, Level level) { + int count = 0; + for (ILoggingEvent event : appender.list) { + if (event.getLevel() == level) { + count++; + } + } + return count; + } + + private static class CyclicException extends RuntimeException { + + private Throwable next; + + CyclicException(String message) { + super(message, null); + } + + void setNext(Throwable next) { + this.next = next; + } + @Override + public synchronized Throwable getCause() { + return next; + } } -} \ No newline at end of file +} diff --git a/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcErrorSanitizationIntegrationTest.java b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcErrorSanitizationIntegrationTest.java new file mode 100644 index 00000000000..e9ba99cdd66 --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcErrorSanitizationIntegrationTest.java @@ -0,0 +1,729 @@ +package org.tron.core.services.jsonrpc; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.googlecode.jsonrpc4j.JsonRpcInterceptor; +import com.googlecode.jsonrpc4j.JsonRpcMethod; +import com.googlecode.jsonrpc4j.JsonRpcServer; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ExecutionException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.tron.common.parameter.CommonParameter; +import org.tron.core.exception.ItemNotFoundException; +import org.tron.core.exception.TronError; +import org.tron.core.exception.jsonrpc.JsonRpcInternalException; + +public class JsonRpcErrorSanitizationIntegrationTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String SENSITIVE_MARKER = "sensitive-marker"; + + private int savedMaxBatchSize; + private int savedMaxResponseSize; + + @Before + public void setUp() { + CommonParameter parameter = CommonParameter.getInstance(); + savedMaxBatchSize = parameter.jsonRpcMaxBatchSize; + savedMaxResponseSize = parameter.jsonRpcMaxResponseSize; + parameter.jsonRpcMaxBatchSize = 0; + parameter.jsonRpcMaxResponseSize = 0; + } + + @After + public void tearDown() { + CommonParameter parameter = CommonParameter.getInstance(); + parameter.jsonRpcMaxBatchSize = savedMaxBatchSize; + parameter.jsonRpcMaxResponseSize = savedMaxResponseSize; + } + + @Test + public void testUnmappedExceptionIsSanitizedOnWire() throws Exception { + JsonRpcServer server = newServer(new ErrorServiceImpl(), ErrorService.class); + + JsonNode response = handle(server, request("test_unhandled", "[]", 1)); + + assertSanitizedInternalError(response, 1); + assertNoInternalDetails(response.toString()); + } + + @Test + public void testNonFatalErrorIsSanitizedByRealServer() throws Exception { + JsonRpcServer server = newServer(new ErrorServiceImpl(), ErrorService.class); + + JsonNode response = handle(server, request("test_assertion", "[]", 1)); + + assertSanitizedInternalError(response, 1); + assertNoInternalDetails(response.toString()); + } + + @Test + public void testPreHandleJsonAssertionErrorEscapesRealServer() throws Exception { + AssertionError failure = new AssertionError(SENSITIVE_MARKER); + FailingInterceptor interceptor = new FailingInterceptor(2, failure); + JsonRpcServer server = newServer(new ErrorServiceImpl(), ErrorService.class, + Collections.singletonList(interceptor)); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + Assert.assertSame(failure, Assert.assertThrows(AssertionError.class, + () -> server.handleRequest(new ByteArrayInputStream( + request("test_ok", "[]", 2).getBytes(StandardCharsets.UTF_8)), output))); + + Assert.assertEquals(Collections.singletonList(2), interceptor.dispatched); + Assert.assertEquals(0, output.size()); + } + + @Test + public void testServletSingleRecoversInterceptorAssertionError() throws Exception { + FailingInterceptor interceptor = + new FailingInterceptor(2, new AssertionError(SENSITIVE_MARKER)); + TestableServlet servlet = newServlet(newServer(new ErrorServiceImpl(), ErrorService.class, + Collections.singletonList(interceptor))); + + MockHttpServletResponse response = post(servlet, request("test_ok", "[]", 2)); + + assertJsonRpcResponse(response, errorResponse(-32603, "Internal error", "2")); + Assert.assertEquals(Collections.singletonList(2), interceptor.dispatched); + } + + @Test + public void testServletBatchRecoversInterceptorAssertionError() throws Exception { + FailingInterceptor interceptor = + new FailingInterceptor(2, new AssertionError(SENSITIVE_MARKER)); + TestableServlet servlet = newServlet(newServer(new ErrorServiceImpl(), ErrorService.class, + Collections.singletonList(interceptor))); + + MockHttpServletResponse response = post(servlet, + "[" + request("test_ok", "[]", 1) + "," + request("test_ok", "[]", 2) + + "," + request("test_ok", "[]", 3) + "]"); + + assertJsonRpcResponse(response, MAPPER.createArrayNode() + .add(MAPPER.readTree("{\"jsonrpc\":\"2.0\",\"result\":\"ok\",\"id\":1}")) + .add(errorResponse(-32603, "Internal error", "2")) + .add(MAPPER.readTree("{\"jsonrpc\":\"2.0\",\"result\":\"ok\",\"id\":3}"))); + Assert.assertEquals(Arrays.asList(1, 2, 3), interceptor.dispatched); + } + + @Test + public void testServletSinglePropagatesInterceptorFatalError() throws Exception { + TronError fatal = new TronError("fatal-marker", TronError.ErrCode.API_SERVER_INIT); + assertInterceptorFatalPropagates(fatal, fatal, false); + } + + @Test + public void testServletBatchPropagatesInterceptorFatalError() throws Exception { + StackOverflowError fatal = new StackOverflowError("fatal-marker"); + assertInterceptorFatalPropagates(fatal, fatal, true); + } + + @Test + public void testServletSinglePropagatesInterceptorWrappedFatalError() throws Exception { + StackOverflowError fatal = new StackOverflowError("fatal-marker"); + assertInterceptorFatalPropagates(new AssertionError("wrapper-marker", fatal), fatal, false); + } + + @Test + public void testServletBatchPropagatesInterceptorWrappedFatalError() throws Exception { + TronError fatal = new TronError("fatal-marker", TronError.ErrCode.API_SERVER_INIT); + assertInterceptorFatalPropagates(new AssertionError("wrapper-marker", fatal), fatal, true); + } + + @Test + public void testServletForwardsUnknownMethodNotificationError() throws Exception { + assertNotificationErrorIsForwarded("test_missing", -32601, "method not found"); + } + + @Test + public void testServletForwardsArityNotificationError() throws Exception { + assertNotificationErrorIsForwarded("test_echo", -32602, "method parameters invalid"); + } + + @Test + public void testServletForwardsUnmappedNotificationError() throws Exception { + assertNotificationErrorIsForwarded("test_unhandled", -32603, "Internal error"); + } + + @Test + public void testFatalErrorsEscapeRealServer() throws Exception { + ErrorServiceImpl service = new ErrorServiceImpl(); + JsonRpcServer server = newServer(service, ErrorService.class); + + StackOverflowError direct = Assert.assertThrows(StackOverflowError.class, + () -> handle(server, request("test_fatal", "[]", 1))); + Assert.assertSame(service.directFatal, direct); + + TronError tronError = Assert.assertThrows(TronError.class, + () -> handle(server, request("test_tron_error", "[]", 2))); + Assert.assertSame(service.tronError, tronError); + + TronJsonRpc mappedService = mock(TronJsonRpc.class); + StackOverflowError wrappedFatal = new StackOverflowError("wrapped-fatal-marker"); + when(mappedService.getLogs(any(TronJsonRpc.FilterRequest.class))) + .thenThrow(new ExecutionException(wrappedFatal)); + JsonRpcServer mappedServer = newServer(mappedService, TronJsonRpc.class); + + StackOverflowError wrapped = Assert.assertThrows(StackOverflowError.class, + () -> handle(mappedServer, request("eth_getLogs", "[{}]", 2))); + Assert.assertSame(wrappedFatal, wrapped); + } + + @Test + public void testMappedExecutionExceptionUsesFixedMessage() throws Exception { + TronJsonRpc service = mock(TronJsonRpc.class); + when(service.getLogs(any(TronJsonRpc.FilterRequest.class))) + .thenThrow(new ExecutionException(new NullPointerException(SENSITIVE_MARKER))); + JsonRpcServer server = newServer(service, TronJsonRpc.class); + + JsonNode response = handle(server, request("eth_getLogs", "[{}]", 1)); + + assertMappedInternalError(response, 1); + assertNoInternalDetails(response.toString()); + } + + @Test + public void testMappedInterruptedExceptionUsesFixedMessage() throws Exception { + Thread.interrupted(); + try { + TronJsonRpc service = mock(TronJsonRpc.class); + when(service.getLogs(any(TronJsonRpc.FilterRequest.class))).thenAnswer(invocation -> { + Thread.currentThread().interrupt(); + throw new InterruptedException(); + }); + JsonRpcServer server = newServer(service, TronJsonRpc.class); + + JsonNode response = handle(server, request("eth_getLogs", "[{}]", 1)); + + assertMappedInternalError(response, 1); + Assert.assertFalse(response.toString().contains("InterruptedException")); + Assert.assertTrue(Thread.currentThread().isInterrupted()); + } finally { + Thread.interrupted(); + } + } + + @Test + public void testGetFilterLogsAsyncFailuresUseFixedMessage() throws Exception { + TronJsonRpc service = mock(TronJsonRpc.class); + when(service.getFilterLogs("0xdeadbeef")) + .thenThrow(new ExecutionException(new NullPointerException(SENSITIVE_MARKER))) + .thenThrow(new InterruptedException()); + JsonRpcServer server = newServer(service, TronJsonRpc.class); + + JsonNode executionResponse = handle(server, + request("eth_getFilterLogs", "[\"0xdeadbeef\"]", 1)); + JsonNode interruptedResponse = handle(server, + request("eth_getFilterLogs", "[\"0xdeadbeef\"]", 2)); + + assertMappedInternalError(executionResponse, 1); + assertMappedInternalError(interruptedResponse, 2); + assertNoInternalDetails(executionResponse.toString()); + Assert.assertFalse(interruptedResponse.toString().contains("InterruptedException")); + } + + @Test + public void testChainIdentityKeepsDocumentedCodeAndSanitizesDetails() throws Exception { + TronJsonRpc service = mock(TronJsonRpc.class); + // Shaped like production: the exception carries a cause, so the response must withhold the + // exception message, the cause message and both type names. + when(service.ethChainId()).thenThrow( + new JsonRpcInternalException(SENSITIVE_MARKER, new RuntimeException(SENSITIVE_MARKER))); + when(service.getNetVersion()).thenThrow( + new JsonRpcInternalException(SENSITIVE_MARKER, new RuntimeException(SENSITIVE_MARKER))); + JsonRpcServer server = newServer(service, TronJsonRpc.class); + + JsonNode chainId = handle(server, request("eth_chainId", "[]", 1)); + JsonNode netVersion = handle(server, request("net_version", "[]", 2)); + + // -32001 is documented as JSON_RPC_UNDERLYING_INTERNAL_ERROR for chain identity lookups, + // so the code is kept while the underlying message and exception type are withheld. + assertChainIdentityError(chainId, 1); + assertChainIdentityError(netVersion, 2); + assertNoInternalDetails(chainId.toString()); + assertNoInternalDetails(netVersion.toString()); + } + + @Test + public void testMappedBusinessMessageIsPreserved() throws Exception { + TronJsonRpc service = mock(TronJsonRpc.class); + when(service.getFilterLogs("0xdeadbeef")) + .thenThrow(new ItemNotFoundException("filter not found")); + JsonRpcServer server = newServer(service, TronJsonRpc.class); + + JsonNode response = handle(server, + request("eth_getFilterLogs", "[\"0xdeadbeef\"]", 1)); + + JsonNode error = response.get("error"); + Assert.assertEquals(-32000, error.get("code").asInt()); + Assert.assertEquals("filter not found", error.get("message").asText()); + Assert.assertEquals("{}", error.get("data").asText()); + } + + @Test + public void testServletBatchIsolatesInvalidElement() throws Exception { + TestableServlet servlet = newServlet(new ErrorServiceImpl()); + + MockHttpServletResponse response = post(servlet, + "[null," + request("test_ok", "[]", 2) + "]"); + JsonNode body = MAPPER.readTree(response.getContentAsByteArray()); + + Assert.assertTrue(body.isArray()); + Assert.assertEquals(2, body.size()); + Assert.assertEquals(-32600, body.get(0).get("error").get("code").asInt()); + Assert.assertTrue(body.get(0).get("id").isNull()); + Assert.assertEquals("ok", body.get(1).get("result").asText()); + Assert.assertEquals(2, body.get(1).get("id").asInt()); + } + + @Test + public void testServletBatchIsolatesUnhandledException() throws Exception { + TestableServlet servlet = newServlet(new ErrorServiceImpl()); + + MockHttpServletResponse response = post(servlet, + "[" + request("test_unhandled", "[]", 1) + "," + + request("test_ok", "[]", 2) + "]"); + JsonNode body = MAPPER.readTree(response.getContentAsByteArray()); + + Assert.assertTrue(body.isArray()); + Assert.assertEquals(2, body.size()); + assertSanitizedInternalError(body.get(0), 1); + Assert.assertEquals("ok", body.get(1).get("result").asText()); + Assert.assertEquals(2, body.get(1).get("id").asInt()); + assertNoInternalDetails(body.toString()); + } + + @Test + public void testServletSingleRequestPreservesTransportContract() throws Exception { + TestableServlet servlet = newServlet(new ErrorServiceImpl()); + + MockHttpServletResponse response = post(servlet, + request("test_ok", "[]", 1)); + JsonNode body = MAPPER.readTree(response.getContentAsByteArray()); + + Assert.assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + Assert.assertEquals("application/json-rpc", response.getContentType()); + Assert.assertEquals("ok", body.get("result").asText()); + Assert.assertEquals(1, body.get("id").asInt()); + } + + @Test + public void testServletSingleNotificationReturnsEmptyBody() throws Exception { + TestableServlet servlet = newServlet(new ErrorServiceImpl()); + + MockHttpServletResponse response = post(servlet, + "{\"jsonrpc\":\"2.0\",\"method\":\"test_ok\",\"params\":[]}"); + + Assert.assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + Assert.assertEquals("application/json-rpc", response.getContentType()); + Assert.assertEquals(0, response.getContentAsByteArray().length); + } + + @Test + public void testServletRejectsNonNullScalarParamsAsInvalidRequest() throws Exception { + TestableServlet servlet = newServlet(new ErrorServiceImpl()); + String[] scalarParams = {"5", "\"value\"", "true"}; + + for (int i = 0; i < scalarParams.length; i++) { + int id = i + 1; + MockHttpServletResponse response = post(servlet, + request("test_ok", scalarParams[i], id)); + JsonNode body = MAPPER.readTree(response.getContentAsByteArray()); + + Assert.assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + Assert.assertEquals("application/json-rpc", response.getContentType()); + assertInvalidRequest(body); + Assert.assertEquals(MAPPER.getNodeFactory().numberNode(id), body.get("id")); + } + } + + @Test + public void testServletScalarParamsApplyRequestIdRules() throws Exception { + TestableServlet servlet = newServlet(new ErrorServiceImpl()); + + MockHttpServletResponse stringIdResponse = post(servlet, + "{\"jsonrpc\":\"2.0\",\"method\":\"test_ok\",\"params\":5," + + "\"id\":\"request-1\"}"); + assertInvalidRequestResponse(stringIdResponse, + MAPPER.getNodeFactory().textNode("request-1")); + + MockHttpServletResponse missingIdResponse = post(servlet, + "{\"jsonrpc\":\"2.0\",\"method\":\"test_ok\",\"params\":5}"); + assertInvalidRequestResponse(missingIdResponse, MAPPER.getNodeFactory().nullNode()); + + MockHttpServletResponse nullIdResponse = post(servlet, + "{\"jsonrpc\":\"2.0\",\"method\":\"test_ok\",\"params\":5,\"id\":null}"); + assertInvalidRequestResponse(nullIdResponse, MAPPER.getNodeFactory().nullNode()); + + MockHttpServletResponse invalidIdResponse = post(servlet, + "{\"jsonrpc\":\"2.0\",\"method\":\"test_ok\",\"params\":5,\"id\":true}"); + assertInvalidRequestResponse(invalidIdResponse, MAPPER.getNodeFactory().nullNode()); + } + + @Test + public void testServletValidatesScalarParamsBeforeMethodLookup() throws Exception { + TestableServlet servlet = newServlet(new ErrorServiceImpl()); + + MockHttpServletResponse scalarResponse = post(servlet, + request("test_unknown", "5", 1)); + JsonNode scalarBody = MAPPER.readTree(scalarResponse.getContentAsByteArray()); + assertInvalidRequest(scalarBody); + Assert.assertEquals(MAPPER.getNodeFactory().numberNode(1), scalarBody.get("id")); + + MockHttpServletResponse structuredResponse = post(servlet, + request("test_unknown", "[]", 2)); + JsonNode structuredBody = MAPPER.readTree(structuredResponse.getContentAsByteArray()); + JsonNode structuredError = structuredBody.get("error"); + Assert.assertEquals(MAPPER.getNodeFactory().numberNode(-32601), + structuredError.get("code")); + Assert.assertEquals(MAPPER.getNodeFactory().textNode("method not found"), + structuredError.get("message")); + Assert.assertFalse(structuredError.has("data")); + Assert.assertEquals(MAPPER.getNodeFactory().numberNode(2), structuredBody.get("id")); + } + + @Test + public void testServletForwardsNullArrayAndObjectParamsToDispatch() throws Exception { + TestableServlet servlet = newServlet(new ErrorServiceImpl()); + + MockHttpServletResponse noArgResponse = post(servlet, + request("test_ok", "null", 1)); + JsonNode noArgBody = MAPPER.readTree(noArgResponse.getContentAsByteArray()); + Assert.assertEquals(MAPPER.getNodeFactory().textNode("ok"), noArgBody.get("result")); + Assert.assertEquals(MAPPER.getNodeFactory().numberNode(1), noArgBody.get("id")); + + MockHttpServletResponse oneArgResponse = post(servlet, + request("test_echo", "[]", 2)); + JsonNode oneArgBody = MAPPER.readTree(oneArgResponse.getContentAsByteArray()); + JsonNode error = oneArgBody.get("error"); + Assert.assertEquals(MAPPER.getNodeFactory().numberNode(-32602), error.get("code")); + Assert.assertEquals(MAPPER.getNodeFactory().textNode("method parameters invalid"), + error.get("message")); + Assert.assertEquals(MAPPER.getNodeFactory().numberNode(2), oneArgBody.get("id")); + + MockHttpServletResponse objectResponse = post(servlet, + request("test_ok", "{}", 3)); + JsonNode objectBody = MAPPER.readTree(objectResponse.getContentAsByteArray()); + Assert.assertEquals(MAPPER.getNodeFactory().textNode("ok"), objectBody.get("result")); + Assert.assertEquals(MAPPER.getNodeFactory().numberNode(3), objectBody.get("id")); + } + + @Test + public void testFatalErrorEscapesServlet() { + ErrorServiceImpl service = new ErrorServiceImpl(); + TestableServlet servlet = newServlet(service); + MockHttpServletResponse response = new MockHttpServletResponse(); + + StackOverflowError thrown = Assert.assertThrows(StackOverflowError.class, + () -> post(servlet, request("test_fatal", "[]", 1), response)); + + Assert.assertSame(service.directFatal, thrown); + assertBareInternalServerError(response); + } + + @Test + public void testMappedFatalErrorEscapesServlet() throws Exception { + TronJsonRpc service = mock(TronJsonRpc.class); + StackOverflowError fatal = new StackOverflowError("wrapped-fatal-marker"); + when(service.getLogs(any(TronJsonRpc.FilterRequest.class))) + .thenThrow(new ExecutionException(fatal)); + TestableServlet servlet = newServlet(service, TronJsonRpc.class); + MockHttpServletResponse response = new MockHttpServletResponse(); + + StackOverflowError thrown = Assert.assertThrows(StackOverflowError.class, + () -> post(servlet, request("eth_getLogs", "[{}]", 1), response)); + + Assert.assertSame(fatal, thrown); + assertBareInternalServerError(response); + } + + @Test + public void testFatalErrorDiscardsAccumulatedBatchResponse() { + ErrorServiceImpl service = new ErrorServiceImpl(); + TestableServlet servlet = newServlet(service); + MockHttpServletResponse response = new MockHttpServletResponse(); + + StackOverflowError thrown = Assert.assertThrows(StackOverflowError.class, + () -> post(servlet, + "[" + request("test_ok", "[]", 1) + "," + + request("test_fatal", "[]", 2) + "]", response)); + + Assert.assertSame(service.directFatal, thrown); + assertBareInternalServerError(response); + } + + private static JsonRpcServer newServer(Object service, Class serviceInterface) { + return newServer(service, serviceInterface, Collections.emptyList()); + } + + private static JsonRpcServer newServer(Object service, Class serviceInterface, + List interceptors) { + JsonRpcServer server = new JsonRpcServer(service, serviceInterface); + server.setErrorResolver(JsonRpcErrorResolver.INSTANCE); + server.setShouldLogInvocationErrors(false); + server.setInterceptorList(interceptors); + return server; + } + + private static TestableServlet newServlet(ErrorServiceImpl service) { + return newServlet(service, ErrorService.class); + } + + private static TestableServlet newServlet(Object service, Class serviceInterface) { + return newServlet(newServer(service, serviceInterface)); + } + + private static TestableServlet newServlet(JsonRpcServer server) { + TestableServlet servlet = new TestableServlet(); + servlet.setRpcServer(server); + return servlet; + } + + private static JsonNode handle(JsonRpcServer server, String json) throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + server.handleRequest( + new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)), output); + return MAPPER.readTree(output.toByteArray()); + } + + private static MockHttpServletResponse post(TestableServlet servlet, String body) + throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + post(servlet, body, response); + return response; + } + + private static void post(TestableServlet servlet, String body, + MockHttpServletResponse response) throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/jsonrpc"); + request.setContent(body.getBytes(StandardCharsets.UTF_8)); + servlet.callDoPost(request, response); + } + + private static String request(String method, String params, int id) { + return "{\"jsonrpc\":\"2.0\",\"method\":\"" + method + + "\",\"params\":" + params + ",\"id\":" + id + "}"; + } + + private static void assertNotificationErrorIsForwarded(String method, int code, String message) + throws Exception { + TestableServlet servlet = newServlet(new ErrorServiceImpl()); + String notification = "{\"jsonrpc\":\"2.0\",\"method\":\"" + method + "\",\"params\":[]}"; + JsonNode expected = errorResponse(code, message, "null"); + + assertJsonRpcResponse(post(servlet, notification), expected); + assertJsonRpcResponse(post(servlet, + "[" + notification + "," + request("test_ok", "[]", 3) + "]"), + MAPPER.createArrayNode().add(expected) + .add(MAPPER.readTree("{\"jsonrpc\":\"2.0\",\"result\":\"ok\",\"id\":3}"))); + } + + private static JsonNode errorResponse(int code, String message, String id) throws IOException { + return MAPPER.readTree("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":" + code + + ",\"message\":\"" + message + "\"},\"id\":" + id + "}"); + } + + private static void assertJsonRpcResponse(MockHttpServletResponse response, JsonNode expected) + throws IOException { + Assert.assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + Assert.assertEquals("application/json-rpc", response.getContentType()); + Assert.assertEquals(expected, MAPPER.reader() + .with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .readTree(response.getContentAsByteArray())); + assertNoInternalDetails(response.getContentAsString()); + } + + private static void assertInterceptorFatalPropagates(Error failure, Error fatal, boolean batch) + throws Exception { + FailingInterceptor interceptor = new FailingInterceptor(2, failure); + TestableServlet servlet = newServlet(newServer(new ErrorServiceImpl(), ErrorService.class, + Collections.singletonList(interceptor))); + MockHttpServletResponse response = new MockHttpServletResponse(); + String request = request("test_ok", "[]", 2); + if (batch) { + request = "[" + request("test_ok", "[]", 1) + "," + request + "," + + request("test_ok", "[]", 3) + "]"; + } + String body = request; + + Assert.assertSame(fatal, Assert.assertThrows(Error.class, () -> post(servlet, body, response))); + + assertBareInternalServerError(response); + Assert.assertEquals(batch ? Arrays.asList(1, 2) : Collections.singletonList(2), + interceptor.dispatched); + } + + private static void assertSanitizedInternalError(JsonNode response, int id) { + JsonNode error = response.get("error"); + Assert.assertEquals(-32603, error.get("code").asInt()); + Assert.assertEquals("Internal error", error.get("message").asText()); + Assert.assertFalse(error.has("data")); + Assert.assertEquals(id, response.get("id").asInt()); + } + + private static void assertInvalidRequest(JsonNode response) { + Assert.assertTrue(response.isObject()); + Assert.assertEquals(MAPPER.getNodeFactory().textNode("2.0"), response.get("jsonrpc")); + JsonNode error = response.get("error"); + Assert.assertEquals(MAPPER.getNodeFactory().numberNode(-32600), error.get("code")); + Assert.assertEquals(MAPPER.getNodeFactory().textNode("Invalid Request"), + error.get("message")); + Assert.assertFalse(error.has("data")); + } + + private static void assertInvalidRequestResponse(MockHttpServletResponse response, + JsonNode expectedId) throws Exception { + Assert.assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + Assert.assertEquals("application/json-rpc", response.getContentType()); + JsonNode body = MAPPER.readTree(response.getContentAsByteArray()); + assertInvalidRequest(body); + Assert.assertEquals(expectedId, body.get("id")); + } + + private static void assertMappedInternalError(JsonNode response, int id) { + JsonNode error = response.get("error"); + Assert.assertEquals(-32000, error.get("code").asInt()); + Assert.assertEquals("Internal error", error.get("message").asText()); + Assert.assertEquals("{}", error.get("data").asText()); + Assert.assertEquals(id, response.get("id").asInt()); + } + + private static void assertChainIdentityError(JsonNode response, int id) { + JsonNode error = response.get("error"); + Assert.assertEquals(-32001, error.get("code").asInt()); + Assert.assertEquals("Chain identity unavailable", error.get("message").asText()); + Assert.assertEquals("{}", error.get("data").asText()); + Assert.assertEquals(id, response.get("id").asInt()); + } + + private static void assertNoInternalDetails(String response) { + Assert.assertFalse(response.contains(SENSITIVE_MARKER)); + Assert.assertFalse(response.contains("java.")); + Assert.assertFalse(response.contains("NullPointerException")); + Assert.assertFalse(response.contains("RuntimeException")); + } + + private static void assertBareInternalServerError(MockHttpServletResponse response) { + Assert.assertEquals(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, response.getStatus()); + Assert.assertEquals(0, response.getContentAsByteArray().length); + Assert.assertTrue(response.isCommitted()); + } + + public interface ErrorService { + + @JsonRpcMethod("test_ok") + String ok(); + + @JsonRpcMethod("test_echo") + String echo(String value); + + @JsonRpcMethod("test_unhandled") + String unhandled(); + + @JsonRpcMethod("test_assertion") + String assertion(); + + @JsonRpcMethod("test_fatal") + String fatal(); + + @JsonRpcMethod("test_tron_error") + String tronError(); + } + + public static class ErrorServiceImpl implements ErrorService { + + private final StackOverflowError directFatal = + new StackOverflowError("direct-fatal-marker"); + private final TronError tronError = + new TronError("tron-fatal-marker", TronError.ErrCode.API_SERVER_INIT); + + @Override + public String ok() { + return "ok"; + } + + @Override + public String echo(String value) { + return value; + } + + @Override + public String unhandled() { + throw new RuntimeException(SENSITIVE_MARKER); + } + + @Override + public String assertion() { + throw new AssertionError(SENSITIVE_MARKER); + } + + @Override + public String fatal() { + throw directFatal; + } + + @Override + public String tronError() { + throw tronError; + } + } + + private static class FailingInterceptor implements JsonRpcInterceptor { + + private final int failureId; + private final Error failure; + private final List dispatched = new ArrayList<>(); + + private FailingInterceptor(int failureId, Error failure) { + this.failureId = failureId; + this.failure = failure; + } + + @Override + public void preHandleJson(JsonNode json) { + int id = json.get("id").intValue(); + dispatched.add(id); + if (id == failureId) { + throw failure; + } + } + + @Override + public void preHandle(Object target, Method method, List params) { + } + + @Override + public void postHandle(Object target, Method method, List params, JsonNode result) { + } + + @Override + public void postHandleJson(JsonNode json) { + } + } + + private static class TestableServlet extends JsonRpcServlet { + + void callDoPost(HttpServletRequest request, HttpServletResponse response) + throws IOException { + doPost(request, response); + } + } +} diff --git a/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletJettyTest.java b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletJettyTest.java new file mode 100644 index 00000000000..0411d5ceac7 --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletJettyTest.java @@ -0,0 +1,300 @@ +package org.tron.core.services.jsonrpc; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.googlecode.jsonrpc4j.JsonRpcMethod; +import com.googlecode.jsonrpc4j.JsonRpcServer; +import java.io.IOException; +import java.lang.reflect.Field; +import java.net.SocketException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.EnumSet; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import javax.servlet.DispatcherType; +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletConfig; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.apache.http.ConnectionClosedException; +import org.apache.http.HttpEntity; +import org.apache.http.NoHttpResponseException; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.util.EntityUtils; +import org.eclipse.jetty.servlet.FilterHolder; +import org.eclipse.jetty.servlet.ServletContextHandler; +import org.eclipse.jetty.servlet.ServletHolder; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.common.TestConstants; +import org.tron.common.application.HttpService; +import org.tron.common.utils.PublicMethod; +import org.tron.core.config.args.Args; +import org.tron.core.services.filter.HttpInterceptor; +import org.tron.core.services.http.RateLimiterServlet; +import org.tron.core.services.ratelimiter.RateLimiterContainer; + +public class JsonRpcServletJettyTest { + + private static final String FATAL_MARKER = "fatal-response-marker"; + private static final String OBSERVATION_HEADER = "X-Test-Observation"; + + @ClassRule + public static final TemporaryFolder TEMPORARY_FOLDER = new TemporaryFolder(); + + private TestJsonRpcHttpService httpService; + private CloseableHttpClient client; + private FatalServiceImpl fatalService; + private final Map> commitObservations = + new ConcurrentHashMap<>(); + + @Before + public void setUp() throws Exception { + Args.setParam(new String[]{"-d", TEMPORARY_FOLDER.newFolder().toString()}, + TestConstants.TEST_CONF); + + fatalService = new FatalServiceImpl(); + client = HttpClients.custom().disableAutomaticRetries() + .setDefaultRequestConfig(RequestConfig.custom() + .setConnectTimeout(5000).setSocketTimeout(5000).build()) + .build(); + } + + private URI startServer(boolean withFilter) throws Exception { + JsonRpcServer rpcServer = new JsonRpcServer(fatalService, FatalService.class); + rpcServer.setErrorResolver(JsonRpcErrorResolver.INSTANCE); + rpcServer.setShouldLogInvocationErrors(false); + + TestJsonRpcServlet servlet = new TestJsonRpcServlet(rpcServer); + Field containerField = RateLimiterServlet.class.getDeclaredField("container"); + containerField.setAccessible(true); + containerField.set(servlet, new RateLimiterContainer()); + + int port = PublicMethod.chooseRandomPort(); + httpService = new TestJsonRpcHttpService(port, servlet, withFilter, commitObservations); + httpService.start().get(10, TimeUnit.SECONDS); + return new URI(String.format("http://localhost:%d/jsonrpc", port)); + } + + @After + public void tearDown() throws Exception { + try { + if (client != null) { + client.close(); + } + } finally { + try { + if (httpService != null) { + httpService.stop(); + } + } finally { + Args.clearParam(); + } + } + } + + @Test + public void fatalErrorDoesNotReachJettyDefaultErrorPage() throws Exception { + URI endpoint = startServer(false); + HttpPost request = new HttpPost(endpoint); + request.setHeader("Accept", "application/json"); + request.setEntity(new StringEntity( + "{\"jsonrpc\":\"2.0\",\"method\":\"test_fatal\",\"params\":[],\"id\":1}", + ContentType.APPLICATION_JSON)); + + try (CloseableHttpResponse response = client.execute(request)) { + HttpEntity entity = response.getEntity(); + byte[] body = entity == null ? new byte[0] : EntityUtils.toByteArray(entity); + String text = new String(body, StandardCharsets.UTF_8); + + Assert.assertEquals(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + response.getStatusLine().getStatusCode()); + Assert.assertEquals(0, body.length); + Assert.assertFalse(text.contains(FATAL_MARKER)); + Assert.assertFalse(text.contains(StackOverflowError.class.getName())); + } catch (NoHttpResponseException | ConnectionClosedException | SocketException expected) { + // A fatal Error may close the connection after the best-effort empty 500 is committed. + } + + Assert.assertTrue("the request must reach the JSON-RPC method", fatalService.invoked.get()); + } + + @Test + public void fatalErrorWithProductionFilterDoesNotReachJettyDefaultErrorPage() throws Exception { + URI endpoint = startServer(true); + String[] acceptTypes = {"application/json", "text/html", "text/plain"}; + for (String acceptType : acceptTypes) { + fatalService.invoked.set(false); + CompletableFuture committed = new CompletableFuture<>(); + commitObservations.put(acceptType, committed); + HttpPost request = new HttpPost(endpoint); + request.setHeader("Accept", acceptType); + request.setHeader("Connection", "close"); + request.setHeader(OBSERVATION_HEADER, acceptType); + request.setEntity(new StringEntity( + "{\"jsonrpc\":\"2.0\",\"method\":\"test_fatal\",\"params\":[],\"id\":1}", + ContentType.APPLICATION_JSON)); + + try (CloseableHttpResponse response = client.execute(request)) { + HttpEntity entity = response.getEntity(); + byte[] body = entity == null ? new byte[0] : EntityUtils.toByteArray(entity); + String text = new String(body, StandardCharsets.UTF_8); + + Assert.assertEquals(acceptType, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + response.getStatusLine().getStatusCode()); + Assert.assertEquals(acceptType, 0, body.length); + Assert.assertFalse(text.contains(FATAL_MARKER)); + Assert.assertFalse(text.contains(StackOverflowError.class.getName())); + } catch (NoHttpResponseException | ConnectionClosedException | SocketException expected) { + // Jetty can abort the connection after the guard commits. Require server-side evidence + // below instead of treating an arbitrary network failure as a successful cleanup. + } + Assert.assertTrue("the underlying response must be committed as an empty 500", + committed.get(5, TimeUnit.SECONDS)); + Assert.assertTrue("the request must reach the method for " + acceptType, + fatalService.invoked.get()); + } + } + + @Test + public void successfulRequestWithProductionFilterPreservesResponse() throws Exception { + HttpPost request = new HttpPost(startServer(true)); + request.setEntity(new StringEntity( + "{\"jsonrpc\":\"2.0\",\"method\":\"test_ok\",\"params\":[],\"id\":7}", + ContentType.APPLICATION_JSON)); + + try (CloseableHttpResponse response = client.execute(request)) { + Assert.assertEquals(200, response.getStatusLine().getStatusCode()); + Assert.assertEquals("application/json-rpc", + ContentType.get(response.getEntity()).getMimeType()); + ObjectMapper mapper = new ObjectMapper(); + JsonNode body = mapper.readTree(EntityUtils.toByteArray(response.getEntity())); + Assert.assertEquals( + mapper.readTree("{\"jsonrpc\":\"2.0\",\"result\":\"ok\",\"id\":7}"), body); + } + } + + public interface FatalService { + + @JsonRpcMethod("test_fatal") + String fatal(); + + @JsonRpcMethod("test_ok") + String ok(); + } + + private static class FatalServiceImpl implements FatalService { + + private final AtomicBoolean invoked = new AtomicBoolean(); + + @Override + public String fatal() { + invoked.set(true); + throw new StackOverflowError(FATAL_MARKER); + } + + @Override + public String ok() { + return "ok"; + } + } + + private static class TestJsonRpcServlet extends JsonRpcServlet { + + private final JsonRpcServer testRpcServer; + + TestJsonRpcServlet(JsonRpcServer testRpcServer) { + this.testRpcServer = testRpcServer; + } + + @Override + public void init(ServletConfig config) throws ServletException { + setRpcServer(testRpcServer); + } + } + + private static class TestJsonRpcHttpService extends HttpService { + + private final JsonRpcServlet servlet; + private final boolean withFilter; + private final Map> commitObservations; + + TestJsonRpcHttpService(int port, JsonRpcServlet servlet, boolean withFilter, + Map> commitObservations) { + this.port = port; + this.contextPath = "/"; + this.servlet = servlet; + this.withFilter = withFilter; + this.commitObservations = commitObservations; + } + + @Override + protected void addServlet(ServletContextHandler context) { + context.addServlet(new ServletHolder(servlet), "/jsonrpc"); + } + + @Override + protected void addFilter(ServletContextHandler context) { + if (withFilter) { + context.addFilter(new FilterHolder(new CommitObserver(commitObservations)), "/*", + EnumSet.of(DispatcherType.REQUEST)); + context.addFilter(new FilterHolder(new HttpInterceptor()), "/*", + EnumSet.of(DispatcherType.REQUEST)); + } + } + } + + private static class CommitObserver implements Filter { + + private final Map> observations; + + CommitObserver(Map> observations) { + this.observations = observations; + } + + @Override + public void init(FilterConfig config) { + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + try { + chain.doFilter(request, response); + } catch (Throwable failure) { + String key = ((HttpServletRequest) request).getHeader(OBSERVATION_HEADER); + CompletableFuture observation = key == null ? null : observations.get(key); + if (observation != null) { + HttpServletResponse actual = (HttpServletResponse) response; + observation.complete(actual.isCommitted() + && actual.getStatus() == HttpServletResponse.SC_INTERNAL_SERVER_ERROR + && "0".equals(actual.getHeader("Content-Length"))); + } + throw failure; + } + } + + @Override + public void destroy() { + } + } +} diff --git a/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletTest.java b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletTest.java index d6c843b5aea..3556695630b 100644 --- a/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletTest.java +++ b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletTest.java @@ -4,29 +4,51 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.googlecode.jsonrpc4j.JsonRpcServer; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; -import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import javax.servlet.ServletResponse; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpServletResponseWrapper; import org.junit.After; import org.junit.Before; import org.junit.Test; +import org.slf4j.LoggerFactory; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.tron.common.parameter.CommonParameter; import org.tron.core.Constant; +import org.tron.core.services.filter.CharResponseWrapper; public class JsonRpcServletTest { @@ -41,9 +63,7 @@ public class JsonRpcServletTest { public void setUp() throws Exception { servlet = new TestableServlet(); mockRpcServer = mock(JsonRpcServer.class); - Field f = JsonRpcServlet.class.getDeclaredField("rpcServer"); - f.setAccessible(true); - f.set(servlet, mockRpcServer); + servlet.setRpcServer(mockRpcServer); savedMaxBatchSize = CommonParameter.getInstance().jsonRpcMaxBatchSize; savedMaxResponseSize = CommonParameter.getInstance().jsonRpcMaxResponseSize; } @@ -120,6 +140,175 @@ public void emptyBatch_returnsInvalidRequest() throws Exception { assertTrue(body.get("id").isNull()); } + @Test + public void invalidRequestIdTypes_returnInvalidRequestWithoutDispatch() throws Exception { + String[] invalidIds = {"true", "{}", "[]"}; + + for (String id : invalidIds) { + MockHttpServletResponse resp = doPost( + "{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\"," + + "\"params\":[],\"id\":" + id + "}"); + assertEquals(200, resp.getStatus()); + assertEquals("application/json-rpc", resp.getContentType()); + assertInvalidRequestWithNullId(MAPPER.readTree(resp.getContentAsByteArray())); + } + + verifyNoInteractions(mockRpcServer); + } + + @Test + public void nullRequestId_isNotRejectedByServletValidation() throws Exception { + int[] callCount = {0}; + doAnswer(inv -> { + callCount[0]++; + return 0; + }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class)); + + doPost("{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\"," + + "\"params\":[],\"id\":null}"); + + assertEquals("a null id is valid JSON-RPC input and must reach dispatch", 1, callCount[0]); + } + + @Test + public void structuredAndAbsentParams_reachRpcServer() throws Exception { + List dispatchedRequests = new ArrayList<>(); + doAnswer(inv -> { + dispatchedRequests.add(MAPPER.readTree((InputStream) inv.getArgument(0))); + return 0; + }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class)); + + String[] requests = { + "{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\",\"id\":1}", + "{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\"," + + "\"params\":null,\"id\":2}", + "{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\"," + + "\"params\":[],\"id\":3}", + "{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\"," + + "\"params\":{},\"id\":4}" + }; + for (String request : requests) { + doPost(request); + } + + assertEquals("all supported params shapes must reach jsonrpc4j", + requests.length, dispatchedRequests.size()); + for (int i = 0; i < requests.length; i++) { + assertEquals("forwarded request must be unchanged at index " + i, + MAPPER.readTree(requests[i]), dispatchedRequests.get(i)); + } + } + + @Test + public void singleScalarParams_isRejectedBeforeDispatch() throws Exception { + MockHttpServletResponse resp = doPost( + "{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\"," + + "\"params\":5,\"id\":42}"); + + assertEquals(200, resp.getStatus()); + assertEquals("application/json-rpc", resp.getContentType()); + JsonNode body = MAPPER.readTree(resp.getContentAsByteArray()); + assertEquals(MAPPER.getNodeFactory().textNode("2.0"), body.get("jsonrpc")); + assertEquals(MAPPER.getNodeFactory().numberNode(-32600), + body.get("error").get("code")); + assertEquals(MAPPER.getNodeFactory().textNode("Invalid Request"), + body.get("error").get("message")); + assertFalse(body.get("error").has("data")); + assertEquals(MAPPER.getNodeFactory().numberNode(42), body.get("id")); + verifyNoInteractions(mockRpcServer); + } + + @Test + public void batchInvalidRequestId_isIsolatedFromValidSiblings() throws Exception { + int[] callCount = {0}; + doAnswer(inv -> { + JsonNode request = MAPPER.readTree((InputStream) inv.getArgument(0)); + OutputStream out = inv.getArgument(1); + out.write(("{\"jsonrpc\":\"2.0\",\"result\":\"ok\",\"id\":" + + request.get("id") + "}").getBytes(StandardCharsets.UTF_8)); + callCount[0]++; + return 0; + }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class)); + + MockHttpServletResponse resp = doPost("[" + + "{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\",\"params\":[],\"id\":1}," + + "{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\",\"params\":[],\"id\":true}," + + "{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\",\"params\":[],\"id\":\"two\"}" + + "]"); + + assertEquals(200, resp.getStatus()); + JsonNode body = MAPPER.readTree(resp.getContentAsByteArray()); + assertTrue(body.isArray()); + assertEquals(3, body.size()); + assertEquals("ok", body.get(0).get("result").asText()); + assertEquals(1, body.get(0).get("id").asInt()); + assertInvalidRequestWithNullId(body.get(1)); + assertEquals("ok", body.get(2).get("result").asText()); + assertEquals("two", body.get(2).get("id").asText()); + assertEquals("only valid requests should reach jsonrpc4j", 2, callCount[0]); + } + + @Test + public void batchScalarParams_isIsolatedFromValidSibling() throws Exception { + int[] callCount = {0}; + JsonNode[] dispatchedRequest = {null}; + doAnswer(inv -> { + dispatchedRequest[0] = MAPPER.readTree((InputStream) inv.getArgument(0)); + OutputStream out = inv.getArgument(1); + out.write(("{\"jsonrpc\":\"2.0\",\"result\":\"ok\",\"id\":" + + dispatchedRequest[0].get("id") + "}").getBytes(StandardCharsets.UTF_8)); + callCount[0]++; + return 0; + }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class)); + + MockHttpServletResponse resp = doPost("[" + + "{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\",\"params\":5,\"id\":1}," + + "{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\",\"params\":[],\"id\":2}" + + "]"); + + assertEquals(200, resp.getStatus()); + JsonNode body = MAPPER.readTree(resp.getContentAsByteArray()); + assertTrue(body.isArray()); + assertEquals(2, body.size()); + assertEquals(MAPPER.getNodeFactory().numberNode(-32600), + body.get(0).get("error").get("code")); + assertEquals(MAPPER.getNodeFactory().textNode("Invalid Request"), + body.get(0).get("error").get("message")); + assertFalse(body.get(0).get("error").has("data")); + assertEquals(MAPPER.getNodeFactory().numberNode(1), body.get(0).get("id")); + assertEquals(MAPPER.getNodeFactory().textNode("ok"), body.get(1).get("result")); + assertEquals(MAPPER.getNodeFactory().numberNode(2), body.get(1).get("id")); + assertEquals("only the valid sibling should reach jsonrpc4j", 1, callCount[0]); + assertEquals(MAPPER.getNodeFactory().numberNode(2), dispatchedRequest[0].get("id")); + assertTrue(dispatchedRequest[0].get("params").isArray()); + } + + @Test + public void batchScalarParamsWithoutId_returnsErrorAndDispatchesValidNotification() + throws Exception { + List dispatchedRequests = new ArrayList<>(); + doAnswer(inv -> { + dispatchedRequests.add(MAPPER.readTree((InputStream) inv.getArgument(0))); + return 0; + }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class)); + + MockHttpServletResponse resp = doPost("[" + + "{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\",\"params\":5}," + + "{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\",\"params\":[]}" + + "]"); + + assertEquals(200, resp.getStatus()); + assertEquals("application/json-rpc", resp.getContentType()); + JsonNode body = MAPPER.readTree(resp.getContentAsByteArray()); + assertTrue(body.isArray()); + assertEquals(1, body.size()); + assertInvalidRequestWithNullId(body.get(0)); + assertEquals("only the valid notification should reach jsonrpc4j", + 1, dispatchedRequests.size()); + assertFalse(dispatchedRequests.get(0).has("id")); + assertTrue(dispatchedRequests.get(0).get("params").isArray()); + } + @Test public void batchLimitDisabled_largeBatchAllowed() throws Exception { CommonParameter.getInstance().jsonRpcMaxBatchSize = 0; @@ -142,28 +331,550 @@ public void batchLimitDisabled_largeBatchAllowed() throws Exception { assertEquals("", resp.getContentAsString()); } - // --- rpcServer.handle exceptions --- + // --- rpcServer.handleRequest exceptions --- @Test public void rpcServerThrowsRuntimeException_returnsInternalError() throws Exception { doThrow(new RuntimeException("server exploded")).when(mockRpcServer) - .handle(any(HttpServletRequest.class), any(HttpServletResponse.class)); + .handleRequest(any(InputStream.class), any(OutputStream.class)); MockHttpServletResponse resp = doPost("{\"method\":\"eth_blockNumber\",\"id\":42}"); assertEquals(200, resp.getStatus()); JsonNode body = MAPPER.readTree(resp.getContentAsString()); assertFalse(body.isArray()); assertEquals(-32603, body.get("error").get("code").asInt()); + assertEquals("Internal error", body.get("error").get("message").asText()); + assertEquals(42, body.get("id").asInt()); + } + + @Test + public void rpcServerThrowsIOException_returnsInternalError() throws Exception { + doThrow(new IOException("server exploded")).when(mockRpcServer) + .handleRequest(any(InputStream.class), any(OutputStream.class)); + + MockHttpServletResponse resp = doPost("{\"method\":\"eth_blockNumber\",\"id\":42}"); + + assertEquals(200, resp.getStatus()); + JsonNode body = MAPPER.readTree(resp.getContentAsByteArray()); + assertEquals(-32603, body.get("error").get("code").asInt()); + assertEquals("Internal error", body.get("error").get("message").asText()); + assertEquals(42, body.get("id").asInt()); + } + + @Test + public void notificationIOException_returnsEmptyResponse() throws Exception { + doThrow(new IOException("server exploded")).when(mockRpcServer) + .handleRequest(any(InputStream.class), any(OutputStream.class)); + + MockHttpServletResponse resp = doPost("{\"method\":\"eth_blockNumber\"}"); + + assertEquals(200, resp.getStatus()); + assertEquals("application/json-rpc", resp.getContentType()); + assertEquals(0, resp.getContentAsByteArray().length); + } + + @Test + public void singleAssertionError_discardsPartialOutputAndReturnsInternalError() throws Exception { + doAnswer(inv -> { + OutputStream output = inv.getArgument(1); + output.write("{\"result\":\"partial-sensitive-marker".getBytes(StandardCharsets.UTF_8)); + throw new AssertionError("assertion-sensitive-marker"); + }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class)); + + assertSingleInternalError(doPost("{\"id\":42}"), "42"); + assertSingleInternalError(doPost("{\"id\":\"request-1\"}"), "\"request-1\""); + } + + @Test + public void singleAssertionErrorWithoutId_keepsEmptyResponse() throws Exception { + doThrow(new AssertionError("assertion-sensitive-marker")).when(mockRpcServer) + .handleRequest(any(InputStream.class), any(OutputStream.class)); + + MockHttpServletResponse response = doPost("{\"method\":\"eth_blockNumber\"}"); + + assertEquals(200, response.getStatus()); + assertEquals("application/json-rpc", response.getContentType()); + assertEquals(0, response.getContentAsByteArray().length); + } + + @Test + public void singleAssertionErrorWithNullId_keepsErrorResponse() throws Exception { + doThrow(new AssertionError("assertion-sensitive-marker")).when(mockRpcServer) + .handleRequest(any(InputStream.class), any(OutputStream.class)); + + assertSingleInternalError(doPost("{\"method\":\"eth_blockNumber\",\"id\":null}"), "null"); + } + + @Test + public void singleWrappedAssertionError_propagatesFatalCauseWithoutLogging() throws Exception { + StackOverflowError fatal = new StackOverflowError("fatal-marker"); + try (BatchLogCapture logs = new BatchLogCapture()) { + assertWrappedFatalPropagates(new AssertionError("wrapper-marker", fatal), fatal, false); + assertTrue(logs.executionEvents().isEmpty()); + } + } + + @Test + public void batchWrappedAssertionError_stopsBeforeLaterDispatchWithoutLogging() throws Exception { + StackOverflowError fatal = new StackOverflowError("fatal-marker"); + try (BatchLogCapture logs = new BatchLogCapture()) { + assertWrappedFatalPropagates(new AssertionError("wrapper-marker", fatal), fatal, true); + assertTrue(logs.executionEvents().isEmpty()); + } } @Test public void batchRpcServerThrows_internalErrorIsArray() throws Exception { doThrow(new RuntimeException("boom")).when(mockRpcServer) .handleRequest(any(InputStream.class), any(OutputStream.class)); - MockHttpServletResponse resp = doPost("[{\"method\":\"eth_blockNumber\"}]"); + MockHttpServletResponse resp = doPost( + "[{\"method\":\"eth_blockNumber\",\"id\":\"request-1\"}]"); assertEquals(200, resp.getStatus()); JsonNode body = MAPPER.readTree(resp.getContentAsString()); assertTrue("batch internal error must be an array", body.isArray()); assertEquals(-32603, body.get(0).get("error").get("code").asInt()); + assertEquals("Internal error", body.get(0).get("error").get("message").asText()); + assertEquals("request-1", body.get(0).get("id").asText()); + } + + @Test + public void batchRpcServerThrowsIOException_internalErrorIsArray() throws Exception { + doThrow(new IOException("boom")).when(mockRpcServer) + .handleRequest(any(InputStream.class), any(OutputStream.class)); + + MockHttpServletResponse resp = doPost( + "[{\"method\":\"eth_blockNumber\",\"id\":\"request-1\"}]"); + + assertEquals(200, resp.getStatus()); + JsonNode body = MAPPER.readTree(resp.getContentAsByteArray()); + assertTrue(body.isArray()); + assertEquals(-32603, body.get(0).get("error").get("code").asInt()); + assertEquals("Internal error", body.get(0).get("error").get("message").asText()); + assertEquals("request-1", body.get(0).get("id").asText()); + } + + @Test + public void fatalError_commitsBare500AndRethrowsOriginal() throws Exception { + StackOverflowError fatal = new StackOverflowError("fatal-marker"); + doThrow(fatal).when(mockRpcServer) + .handleRequest(any(InputStream.class), any(OutputStream.class)); + MockHttpServletResponse response = new MockHttpServletResponse(); + + StackOverflowError thrown = assertThrows(StackOverflowError.class, + () -> doPost("{\"method\":\"eth_blockNumber\",\"id\":1}", response)); + + assertSame(fatal, thrown); + assertEquals(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, response.getStatus()); + assertEquals(0, response.getContentAsByteArray().length); + assertTrue(response.isCommitted()); + } + + @Test + public void singleWrappedRuntimeException_propagatesFatalCause() throws Exception { + StackOverflowError fatal = new StackOverflowError("fatal-marker"); + assertWrappedFatalPropagates(new RuntimeException("wrapper", fatal), fatal, false); + } + + @Test + public void singleWrappedIOException_propagatesFatalCause() throws Exception { + StackOverflowError fatal = new StackOverflowError("fatal-marker"); + assertWrappedFatalPropagates(new IOException("wrapper", fatal), fatal, false); + } + + @Test + public void batchWrappedRuntimeException_stopsBeforeLaterDispatch() throws Exception { + StackOverflowError fatal = new StackOverflowError("fatal-marker"); + assertWrappedFatalPropagates(new RuntimeException("wrapper", fatal), fatal, true); + } + + @Test + public void batchWrappedIOException_stopsBeforeLaterDispatch() throws Exception { + StackOverflowError fatal = new StackOverflowError("fatal-marker"); + assertWrappedFatalPropagates(new IOException("wrapper", fatal), fatal, true); + } + + @Test + public void cleanupIOException_doesNotReplaceOriginalFatalError() throws Exception { + HttpServletResponse response = mock(HttpServletResponse.class); + doThrow(new IOException("cleanup-io-marker")).when(response).flushBuffer(); + assertCleanupFailureDoesNotReplaceFatal(response); + verify(response).flushBuffer(); + } + + @Test + public void cleanupError_doesNotReplaceOriginalFatalError() throws Exception { + HttpServletResponse response = mock(HttpServletResponse.class); + doThrow(new OutOfMemoryError("cleanup-error-marker")).when(response).flushBuffer(); + assertCleanupFailureDoesNotReplaceFatal(response); + verify(response).flushBuffer(); + } + + @Test + public void fatalError_unwrapsNestedResponsesBeforeCommitting() throws Exception { + MockHttpServletResponse actual = new MockHttpServletResponse(); + actual.getOutputStream().write("partial-response-marker".getBytes(StandardCharsets.UTF_8)); + HttpServletResponse wrapped = new HttpServletResponseWrapper( + new CharResponseWrapper(new CharResponseWrapper(actual))); + + assertCleanupFailureDoesNotReplaceFatal(wrapped); + + assertEquals(500, actual.getStatus()); + assertEquals(0, actual.getContentAsByteArray().length); + assertTrue(actual.isCommitted()); + } + + @Test + public void fatalError_atWrapperDepthLimitStillCommits() throws Exception { + MockHttpServletResponse actual = new MockHttpServletResponse(); + HttpServletResponse wrapped = actual; + for (int i = 0; i < 16; i++) { + wrapped = new CharResponseWrapper(wrapped); + } + + assertCleanupFailureDoesNotReplaceFatal(wrapped); + + assertEquals(500, actual.getStatus()); + assertEquals(0, actual.getContentAsByteArray().length); + assertTrue(actual.isCommitted()); + } + + @Test(timeout = 5000) + public void fatalError_selfReferencingWrapperAbandonsCleanup() throws Exception { + TrackingResponseWrapper wrapper = new TrackingResponseWrapper(new MockHttpServletResponse()); + wrapper.setResponse(wrapper); + + assertCleanupFailureDoesNotReplaceFatal(wrapper); + + assertEquals("cleanup must not delegate into a self-reference", 0, wrapper.cleanupCalls); + } + + @Test(timeout = 5000) + public void fatalError_cyclicWrappersAbandonCleanup() throws Exception { + TrackingResponseWrapper first = new TrackingResponseWrapper(new MockHttpServletResponse()); + TrackingResponseWrapper second = new TrackingResponseWrapper(first); + first.setResponse(second); + + assertCleanupFailureDoesNotReplaceFatal(first); + + assertEquals("cleanup must not delegate into a cycle", 0, first.cleanupCalls); + assertEquals("cleanup must not delegate into a cycle", 0, second.cleanupCalls); + } + + @Test(timeout = 5000) + public void fatalError_excessiveWrapperDepthAbandonsCleanup() throws Exception { + MockHttpServletResponse actual = new MockHttpServletResponse(); + List wrappers = new ArrayList<>(); + HttpServletResponse wrapped = actual; + for (int i = 0; i < 17; i++) { + TrackingResponseWrapper wrapper = new TrackingResponseWrapper(wrapped); + wrappers.add(wrapper); + wrapped = wrapper; + } + + assertCleanupFailureDoesNotReplaceFatal(wrapped); + + for (TrackingResponseWrapper wrapper : wrappers) { + assertEquals("cleanup must not delegate beyond the depth limit", 0, wrapper.cleanupCalls); + } + assertFalse(actual.isCommitted()); + } + + @Test + public void unwrappingFailure_doesNotReplaceOriginalFatalError() throws Exception { + boolean[] attempted = {false}; + HttpServletResponseWrapper response = new HttpServletResponseWrapper( + new MockHttpServletResponse()) { + @Override + public ServletResponse getResponse() { + attempted[0] = true; + throw new IllegalStateException("unwrap-failure-marker"); + } + }; + + assertCleanupFailureDoesNotReplaceFatal(response); + + assertTrue("cleanup must attempt to unwrap the response", attempted[0]); + } + + @Test + public void fatalError_doesNotResetCommittedUnderlyingResponse() throws Exception { + MockHttpServletResponse actual = new MockHttpServletResponse(); + byte[] body = "already-committed".getBytes(StandardCharsets.UTF_8); + actual.getOutputStream().write(body); + actual.flushBuffer(); + + assertCleanupFailureDoesNotReplaceFatal(new CharResponseWrapper(actual)); + + assertEquals(200, actual.getStatus()); + assertArrayEquals(body, actual.getContentAsByteArray()); + assertTrue(actual.isCommitted()); + } + + @Test + public void batchMalformedRpcServerResponse_preservesRequestId() throws Exception { + doAnswer(inv -> { + OutputStream out = inv.getArgument(1); + out.write("not-json".getBytes(StandardCharsets.UTF_8)); + return 0; + }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class)); + + MockHttpServletResponse resp = doPost( + "[{\"method\":\"eth_blockNumber\",\"id\":42}]"); + + JsonNode body = MAPPER.readTree(resp.getContentAsByteArray()); + assertTrue(body.isArray()); + assertEquals(-32603, body.get(0).get("error").get("code").asInt()); + assertEquals("Internal error", body.get(0).get("error").get("message").asText()); + assertEquals(42, body.get(0).get("id").asInt()); + } + + @Test + public void batchRuntimeException_preservesSiblingsAndContinues() throws Exception { + List dispatched = stubBatchWithMiddleFailure(new RuntimeException("failure-marker")); + + MockHttpServletResponse response = doPost("[{\"id\":1},{\"id\":2},{\"id\":3}]"); + + assertRecoveredBatch(response, "2"); + assertEquals(MAPPER.readTree("[{\"id\":1},{\"id\":2},{\"id\":3}]"), + MAPPER.valueToTree(dispatched)); + } + + @Test + public void batchIOException_preservesSiblingsAndContinues() throws Exception { + List dispatched = stubBatchWithMiddleFailure(new IOException("failure-marker")); + + MockHttpServletResponse response = doPost("[{\"id\":1},{\"id\":2},{\"id\":3}]"); + + assertRecoveredBatch(response, "2"); + assertEquals(MAPPER.readTree("[{\"id\":1},{\"id\":2},{\"id\":3}]"), + MAPPER.valueToTree(dispatched)); + } + + @Test + public void batchAssertionError_discardsPartialOutputAndPreservesSiblings() throws Exception { + List dispatched = stubBatchWithMiddleFailure( + new AssertionError("assertion-sensitive-marker"), true); + String request = "[{\"id\":1},{\"id\":2},{\"id\":3}]"; + + MockHttpServletResponse response = doPost(request); + + assertFalse(response.getContentAsString().contains("partial-sensitive-marker")); + assertFalse(response.getContentAsString().contains("assertion-sensitive-marker")); + assertRecoveredBatch(response, "2"); + assertEquals(recoveredBatch("2"), MAPPER.reader() + .with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .readTree(response.getContentAsByteArray())); + assertEquals(MAPPER.readTree(request), MAPPER.valueToTree(dispatched)); + } + + @Test + public void batchAssertionErrorWithoutId_keepsErrorNodeAndContinues() throws Exception { + List dispatched = stubBatchWithMiddleFailure( + new AssertionError("assertion-sensitive-marker")); + String request = "[{\"id\":1},{\"method\":\"eth_blockNumber\"},{\"id\":3}]"; + + MockHttpServletResponse response = doPost(request); + + assertRecoveredBatch(response, "null"); + assertEquals(MAPPER.readTree(request), MAPPER.valueToTree(dispatched)); + } + + @Test + public void batchAssertionErrors_logOnlyFirstStackPerBatch() throws Exception { + List dispatched = stubBatchWithTwoFailures( + new AssertionError("first-sensitive-marker"), + new AssertionError("repeat-sensitive-marker")); + + try (BatchLogCapture logs = new BatchLogCapture()) { + for (int i = 0; i < 2; i++) { + assertBatchWithTwoRecoveredFailures( + doPost("[{\"id\":1},{\"id\":2},{\"id\":3},{\"id\":4}]")); + } + + assertEquals(Arrays.asList(1, 2, 3, 4, 1, 2, 3, 4), dispatched); + List events = logs.events(); + assertEquals(4, events.size()); + assertBoundedBatchLogs(events.subList(0, 2), AssertionError.class, AssertionError.class); + assertBoundedBatchLogs(events.subList(2, 4), AssertionError.class, AssertionError.class); + } + } + + @Test + public void batchMultipleFailures_logsOnlyFirstStackAndPreservesResponses() throws Exception { + List dispatched = stubBatchWithTwoFailures( + new IOException("first-sensitive-marker"), + new IllegalStateException("repeat-sensitive-marker")); + + try (BatchLogCapture logs = new BatchLogCapture()) { + MockHttpServletResponse response = doPost("[{\"id\":1},{\"id\":2},{\"id\":3},{\"id\":4}]"); + + assertBatchWithTwoRecoveredFailures(response); + assertEquals(Arrays.asList(1, 2, 3, 4), dispatched); + assertBoundedBatchLogs(logs.events()); + } + } + + @Test + public void consecutiveBatches_eachLogTheirFirstFailure() throws Exception { + List dispatched = stubBatchWithTwoFailures( + new IOException("first-sensitive-marker"), + new IllegalStateException("repeat-sensitive-marker")); + + try (BatchLogCapture logs = new BatchLogCapture()) { + for (int i = 0; i < 2; i++) { + assertBatchWithTwoRecoveredFailures( + doPost("[{\"id\":1},{\"id\":2},{\"id\":3},{\"id\":4}]")); + } + + assertEquals(Arrays.asList(1, 2, 3, 4, 1, 2, 3, 4), dispatched); + List events = logs.events(); + assertEquals(4, events.size()); + assertBoundedBatchLogs(events.subList(0, 2)); + assertBoundedBatchLogs(events.subList(2, 4)); + } + } + + @Test + public void batchFatalAfterOrdinaryFailure_doesNotLogFatalOrContinue() throws Exception { + StackOverflowError fatal = new StackOverflowError("fatal-sensitive-marker"); + List dispatched = stubBatchWithTwoFailures( + new IOException("first-sensitive-marker"), new IOException("wrapped-fatal", fatal)); + MockHttpServletResponse response = new MockHttpServletResponse(); + + try (BatchLogCapture logs = new BatchLogCapture()) { + assertSame(fatal, assertThrows(StackOverflowError.class, + () -> doPost("[{\"id\":1},{\"id\":2},{\"id\":3},{\"id\":4}]", response))); + + assertEquals(Arrays.asList(1, 2, 3), dispatched); + assertEquals(500, response.getStatus()); + assertEquals(0, response.getContentAsByteArray().length); + assertTrue(response.isCommitted()); + List events = logs.events(); + assertEquals(1, events.size()); + assertFirstBatchFailureLog(events.get(0)); + } + } + + @Test + public void batchMalformedResponse_preservesSiblingsAndContinues() throws Exception { + List dispatched = stubBatchWithMalformedMiddleResponse(); + + MockHttpServletResponse response = doPost("[{\"id\":1},{\"id\":2},{\"id\":3}]"); + + assertRecoveredBatch(response, "2"); + assertEquals(Arrays.asList(1, 2, 3), dispatched); + } + + @Test + public void batchSerializationFailure_preservesSiblingsAndContinues() throws Exception { + int[] serializationAttempts = {0}; + ObjectNode failingRequest = new ObjectNode(MAPPER.getNodeFactory()) { + @Override + public void serialize(JsonGenerator generator, SerializerProvider provider) + throws IOException { + serializationAttempts[0]++; + throw new JsonProcessingException("serialization-marker") {}; + } + }; + failingRequest.put("id", 2); + ArrayNode requests = MAPPER.createArrayNode(); + requests.addObject().put("id", 1); + requests.add(failingRequest); + requests.addObject().put("id", 3); + List dispatched = stubBatchWithMalformedMiddleResponse(); + MockHttpServletResponse response = new MockHttpServletResponse(); + Method handleBatch = JsonRpcServlet.class.getDeclaredMethod("handleBatch", + HttpServletResponse.class, JsonNode.class, int.class); + handleBatch.setAccessible(true); + + handleBatch.invoke(servlet, response, requests, 0); + + assertRecoveredBatch(response, "2"); + assertEquals(Arrays.asList(1, 3), dispatched); + assertEquals(1, serializationAttempts[0]); + } + + @Test + public void batchFailureWithoutId_keepsErrorNodeAndContinues() throws Exception { + List dispatched = stubBatchWithMiddleFailure(new IOException("failure-marker")); + String request = "[{\"id\":1},{\"method\":\"eth_blockNumber\"},{\"id\":3}]"; + + MockHttpServletResponse response = doPost(request); + + assertRecoveredBatch(response, "null"); + assertEquals(MAPPER.readTree(request), MAPPER.valueToTree(dispatched)); + } + + @Test + public void batchRecoveredError_atExactLimitDoesNotOverflow() throws Exception { + List dispatched = stubBatchWithMiddleFailure(new IOException("failure-marker")); + int limit = MAPPER.writeValueAsBytes(recoveredBatch("2")).length; + CommonParameter.getInstance().jsonRpcMaxResponseSize = limit; + + MockHttpServletResponse response = doPost("[{\"id\":1},{\"id\":2},{\"id\":3}]"); + + assertRecoveredBatch(response, "2"); + assertEquals(limit, response.getContentAsByteArray().length); + assertEquals(3, dispatched.size()); + } + + @Test + public void batchMalformedResponse_countsOnlyReplacementBytes() throws Exception { + List dispatched = stubBatchWithMalformedMiddleResponse(); + int limit = MAPPER.writeValueAsBytes(recoveredBatch("2")).length; + CommonParameter.getInstance().jsonRpcMaxResponseSize = limit; + + MockHttpServletResponse response = doPost("[{\"id\":1},{\"id\":2},{\"id\":3}]"); + + assertRecoveredBatch(response, "2"); + assertEquals(limit, response.getContentAsByteArray().length); + assertEquals(Arrays.asList(1, 2, 3), dispatched); + } + + @Test + public void batchRecoveredError_consumesBudgetForLaterResponse() throws Exception { + List dispatched = stubBatchWithMiddleFailure(new IOException("failure-marker")); + CommonParameter.getInstance().jsonRpcMaxResponseSize = + MAPPER.writeValueAsBytes(recoveredBatch("2")).length - 1; + + MockHttpServletResponse response = doPost("[{\"id\":1},{\"id\":2},{\"id\":3}]"); + + assertRecoveredErrorConsumesBudget(response); + assertEquals(MAPPER.readTree("[{\"id\":1},{\"id\":2},{\"id\":3}]"), + MAPPER.valueToTree(dispatched)); + } + + @Test + public void batchMalformedResponse_replacementConsumesBudgetForLaterResponse() throws Exception { + List dispatched = stubBatchWithMalformedMiddleResponse(); + CommonParameter.getInstance().jsonRpcMaxResponseSize = + MAPPER.writeValueAsBytes(recoveredBatch("2")).length - 1; + + MockHttpServletResponse response = doPost("[{\"id\":1},{\"id\":2},{\"id\":3}]"); + + assertRecoveredErrorConsumesBudget(response); + assertEquals(Arrays.asList(1, 2, 3), dispatched); + } + + @Test + public void batchRecoveredError_overflowStopsLaterDispatch() throws Exception { + List dispatched = stubBatchWithMiddleFailure(new IOException("failure-marker")); + CommonParameter.getInstance().jsonRpcMaxResponseSize = limitBeforeSecondErrorFits(); + + MockHttpServletResponse response = doPost("[{\"id\":1},{\"id\":2},{\"id\":3}]"); + + assertBatchErrorOverflow(response); + assertEquals(2, dispatched.size()); + } + + @Test + public void batchMalformedResponse_replacementCanTriggerOverflow() throws Exception { + List dispatched = stubBatchWithMalformedMiddleResponse(); + CommonParameter.getInstance().jsonRpcMaxResponseSize = limitBeforeSecondErrorFits(); + + MockHttpServletResponse response = doPost("[{\"id\":1},{\"id\":2},{\"id\":3}]"); + + assertBatchErrorOverflow(response); + assertEquals(Arrays.asList(1, 2), dispatched); } // --- response size limit --- @@ -173,10 +884,10 @@ public void responseTooLarge_returnsSingleErrorObject() throws Exception { int limit = 50; CommonParameter.getInstance().jsonRpcMaxResponseSize = limit; doAnswer(inv -> { - HttpServletResponse r = inv.getArgument(1); - r.getOutputStream().write(new byte[limit + 1]); - return null; - }).when(mockRpcServer).handle(any(HttpServletRequest.class), any(HttpServletResponse.class)); + OutputStream out = inv.getArgument(1); + out.write(new byte[limit + 1]); + return 0; + }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class)); MockHttpServletResponse resp = doPost("{\"method\":\"eth_getLogs\",\"id\":1}"); assertEquals(200, resp.getStatus()); @@ -233,16 +944,42 @@ public void batchShortCircuitsOnOverflow() throws Exception { assertEquals("third sub-request must not be executed after overflow", 2, callCount[0]); } + @Test + public void batchInvalidRequestId_afterOverflowStillReturnsInvalidRequest() throws Exception { + int limit = 50; + CommonParameter.getInstance().jsonRpcMaxResponseSize = limit; + int[] callCount = {0}; + doAnswer(inv -> { + OutputStream out = inv.getArgument(1); + out.write(new byte[limit + 1]); + callCount[0]++; + return 0; + }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class)); + + MockHttpServletResponse resp = doPost("[" + + "{\"jsonrpc\":\"2.0\",\"method\":\"eth_getLogs\",\"id\":1}," + + "{\"jsonrpc\":\"2.0\",\"method\":\"eth_getLogs\",\"id\":{}}" + + "]"); + + JsonNode body = MAPPER.readTree(resp.getContentAsByteArray()); + assertTrue(body.isArray()); + assertEquals(2, body.size()); + assertEquals(-32003, body.get(0).get("error").get("code").asInt()); + assertEquals(1, body.get(0).get("id").asInt()); + assertInvalidRequestWithNullId(body.get(1)); + assertEquals("invalid requests must not be dispatched after overflow", 1, callCount[0]); + } + // --- normal path --- @Test public void normalRequest_commitsRpcServerResponse() throws Exception { byte[] rpcResp = "{\"result\":\"0x1\"}".getBytes(StandardCharsets.UTF_8); doAnswer(inv -> { - HttpServletResponse r = inv.getArgument(1); - r.getOutputStream().write(rpcResp); - return null; - }).when(mockRpcServer).handle(any(HttpServletRequest.class), any(HttpServletResponse.class)); + OutputStream out = inv.getArgument(1); + out.write(rpcResp); + return 0; + }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class)); MockHttpServletResponse resp = doPost("{\"method\":\"eth_blockNumber\",\"id\":1}"); assertEquals(200, resp.getStatus()); @@ -417,14 +1154,293 @@ public void tooManyTokens_returnsParseError() throws Exception { // --- helpers --- + private List stubBatchWithTwoFailures(Throwable first, Throwable second) + throws Exception { + List dispatched = new ArrayList<>(); + doAnswer(inv -> { + int id = MAPPER.readTree((InputStream) inv.getArgument(0)).get("id").asInt(); + dispatched.add(id); + if (id == 2) { + throw first; + } + if (id == 3) { + throw second; + } + OutputStream out = inv.getArgument(1); + out.write(successResponse(id)); + return 0; + }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class)); + return dispatched; + } + + private static void assertBatchWithTwoRecoveredFailures(MockHttpServletResponse response) + throws IOException { + assertEquals(200, response.getStatus()); + assertEquals("application/json-rpc", response.getContentType()); + assertEquals(MAPPER.readTree("[{\"jsonrpc\":\"2.0\",\"result\":\"ok\",\"id\":1}," + + "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"Internal error\"}," + + "\"id\":2},{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603," + + "\"message\":\"Internal error\"},\"id\":3}," + + "{\"jsonrpc\":\"2.0\",\"result\":\"ok\",\"id\":4}]"), + MAPPER.readTree(response.getContentAsByteArray())); + } + + private static void assertBoundedBatchLogs(List events) { + assertBoundedBatchLogs(events, IOException.class, IllegalStateException.class); + } + + private static void assertBoundedBatchLogs(List events, + Class firstType, Class repeatedType) { + assertEquals(2, events.size()); + assertFirstBatchFailureLog(events.get(0), firstType); + ILoggingEvent repeated = events.get(1); + assertEquals(Level.DEBUG, repeated.getLevel()); + assertNull(repeated.getThrowableProxy()); + assertArrayEquals(new Object[]{2, repeatedType.getName()}, + repeated.getArgumentArray()); + assertFalse(repeated.getFormattedMessage().contains("repeat-sensitive-marker")); + } + + private static void assertFirstBatchFailureLog(ILoggingEvent event) { + assertFirstBatchFailureLog(event, IOException.class); + } + + private static void assertFirstBatchFailureLog(ILoggingEvent event, + Class failureType) { + assertEquals(Level.ERROR, event.getLevel()); + assertEquals("RPC execution failed for batch sub-request 1", event.getFormattedMessage()); + assertEquals(failureType.getName(), event.getThrowableProxy().getClassName()); + assertEquals("first-sensitive-marker", event.getThrowableProxy().getMessage()); + assertTrue(event.getThrowableProxy().getStackTraceElementProxyArray().length > 0); + } + + private static class BatchLogCapture implements AutoCloseable { + private final Logger apiLogger = (Logger) LoggerFactory.getLogger("API"); + private final Level originalLevel = apiLogger.getLevel(); + private final ListAppender appender = new ListAppender<>(); + + private BatchLogCapture() { + appender.start(); + apiLogger.addAppender(appender); + apiLogger.setLevel(Level.DEBUG); + } + + private List events() { + return matchingEvents("RPC execution failed for batch sub-request "); + } + + private List executionEvents() { + return matchingEvents("RPC execution failed"); + } + + private List matchingEvents(String prefix) { + List events = new ArrayList<>(); + for (ILoggingEvent event : appender.list) { + if (event.getMessage().startsWith(prefix)) { + events.add(event); + } + } + return events; + } + + @Override + public void close() { + apiLogger.setLevel(originalLevel); + apiLogger.detachAppender(appender); + appender.stop(); + } + } + + private List stubBatchWithMiddleFailure(Throwable failure) throws Exception { + return stubBatchWithMiddleFailure(failure, false); + } + + private List stubBatchWithMiddleFailure(Throwable failure, boolean partialOutput) + throws Exception { + List dispatched = new ArrayList<>(); + doAnswer(inv -> { + JsonNode request = MAPPER.readTree((InputStream) inv.getArgument(0)); + dispatched.add(request); + if (dispatched.size() == 2) { + if (partialOutput) { + OutputStream output = inv.getArgument(1); + output.write("{\"result\":\"partial-sensitive-marker".getBytes(StandardCharsets.UTF_8)); + } + throw failure; + } + OutputStream out = inv.getArgument(1); + out.write(successResponse(request.get("id").asInt())); + return 0; + }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class)); + return dispatched; + } + + private List stubBatchWithMalformedMiddleResponse() throws Exception { + List dispatched = new ArrayList<>(); + doAnswer(inv -> { + JsonNode request = MAPPER.readTree((InputStream) inv.getArgument(0)); + int id = request.get("id").asInt(); + dispatched.add(id); + OutputStream out = inv.getArgument(1); + out.write(id == 2 ? "not-json".getBytes(StandardCharsets.UTF_8) : successResponse(id)); + return 0; + }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class)); + return dispatched; + } + + private static byte[] successResponse(int id) { + return ("{\"jsonrpc\":\"2.0\",\"result\":\"ok\",\"id\":" + id + "}") + .getBytes(StandardCharsets.UTF_8); + } + + private static JsonNode recoveredBatch(String middleId) throws IOException { + return MAPPER.readTree("[{\"jsonrpc\":\"2.0\",\"result\":\"ok\",\"id\":1}," + + "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"Internal error\"}," + + "\"id\":" + middleId + "},{\"jsonrpc\":\"2.0\",\"result\":\"ok\",\"id\":3}]"); + } + + private static void assertRecoveredBatch(MockHttpServletResponse response, String middleId) + throws IOException { + assertEquals(200, response.getStatus()); + assertEquals("application/json-rpc", response.getContentType()); + assertEquals(recoveredBatch(middleId), MAPPER.readTree(response.getContentAsByteArray())); + } + + private static int limitBeforeSecondErrorFits() throws IOException { + ArrayNode firstTwo = MAPPER.createArrayNode(); + firstTwo.add(recoveredBatch("2").get(0)); + firstTwo.add(recoveredBatch("2").get(1)); + return MAPPER.writeValueAsBytes(firstTwo).length - 1; + } + + private static void assertRecoveredErrorConsumesBudget(MockHttpServletResponse response) + throws IOException { + assertEquals(200, response.getStatus()); + assertEquals("application/json-rpc", response.getContentType()); + ArrayNode expected = (ArrayNode) recoveredBatch("2"); + expected.set(2, MAPPER.readTree("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32003," + + "\"message\":\"Response exceeds the limit of " + + CommonParameter.getInstance().getJsonRpcMaxResponseSize() + + " bytes\"},\"id\":3}")); + assertEquals(expected, MAPPER.readTree(response.getContentAsByteArray())); + } + + private static void assertBatchErrorOverflow(MockHttpServletResponse response) + throws IOException { + assertEquals(200, response.getStatus()); + assertEquals("application/json-rpc", response.getContentType()); + JsonNode body = MAPPER.readTree(response.getContentAsByteArray()); + assertEquals(3, body.size()); + assertEquals(recoveredBatch("2").get(0), body.get(0)); + for (int i = 1; i < body.size(); i++) { + assertEquals("2.0", body.get(i).get("jsonrpc").asText()); + assertEquals(-32003, body.get(i).get("error").get("code").asInt()); + assertEquals("Response exceeds the limit of " + + CommonParameter.getInstance().getJsonRpcMaxResponseSize() + " bytes", + body.get(i).get("error").get("message").asText()); + assertFalse(body.get(i).get("error").has("data")); + assertEquals(i + 1, body.get(i).get("id").asInt()); + } + } + + private static void assertInvalidRequestWithNullId(JsonNode response) { + assertEquals(MAPPER.getNodeFactory().textNode("2.0"), response.get("jsonrpc")); + assertEquals(MAPPER.getNodeFactory().numberNode(-32600), + response.get("error").get("code")); + assertEquals(MAPPER.getNodeFactory().textNode("Invalid Request"), + response.get("error").get("message")); + assertFalse(response.get("error").has("data")); + assertTrue(response.get("id").isNull()); + } + private MockHttpServletResponse doPost(String body) throws Exception { - MockHttpServletRequest req = new MockHttpServletRequest("POST", "/jsonrpc"); - req.setContent(body.getBytes(StandardCharsets.UTF_8)); MockHttpServletResponse resp = new MockHttpServletResponse(); - servlet.callDoPost(req, resp); + doPost(body, resp); return resp; } + private void doPost(String body, HttpServletResponse response) throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/jsonrpc"); + request.setContent(body.getBytes(StandardCharsets.UTF_8)); + servlet.callDoPost(request, response); + } + + private void assertCleanupFailureDoesNotReplaceFatal(HttpServletResponse response) + throws Exception { + StackOverflowError fatal = new StackOverflowError("original-fatal-marker"); + doThrow(fatal).when(mockRpcServer) + .handleRequest(any(InputStream.class), any(OutputStream.class)); + + StackOverflowError thrown = assertThrows(StackOverflowError.class, + () -> doPost("{\"method\":\"eth_blockNumber\",\"id\":1}", response)); + + assertSame(fatal, thrown); + } + + private void assertWrappedFatalPropagates(Throwable failure, Error fatal, boolean batch) + throws Exception { + List dispatched = new ArrayList<>(); + doAnswer(inv -> { + JsonNode request = MAPPER.readTree((InputStream) inv.getArgument(0)); + int id = request.get("id").asInt(); + dispatched.add(id); + if (id == (batch ? 2 : 1)) { + throw failure; + } + OutputStream out = inv.getArgument(1); + out.write(("{\"jsonrpc\":\"2.0\",\"result\":\"ok\",\"id\":" + id + "}") + .getBytes(StandardCharsets.UTF_8)); + return 0; + }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class)); + MockHttpServletResponse response = new MockHttpServletResponse(); + String request = batch ? "[{\"id\":1},{\"id\":2},{\"id\":3}]" : "{\"id\":1}"; + + Error thrown = assertThrows(Error.class, () -> doPost(request, response)); + + assertSame(fatal, thrown); + assertEquals(500, response.getStatus()); + assertEquals(0, response.getContentAsByteArray().length); + assertTrue(response.isCommitted()); + assertEquals(batch ? Arrays.asList(1, 2) : Arrays.asList(1), dispatched); + } + + private static void assertSingleInternalError(MockHttpServletResponse response, String id) + throws IOException { + assertEquals(200, response.getStatus()); + assertEquals("application/json-rpc", response.getContentType()); + assertFalse(response.getContentAsString().contains("partial-sensitive-marker")); + assertFalse(response.getContentAsString().contains("assertion-sensitive-marker")); + assertEquals(MAPPER.readTree("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603," + + "\"message\":\"Internal error\"},\"id\":" + id + "}"), + MAPPER.reader().with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .readTree(response.getContentAsByteArray())); + } + + private static class TrackingResponseWrapper extends HttpServletResponseWrapper { + + private int cleanupCalls; + + TrackingResponseWrapper(HttpServletResponse response) { + super(response); + } + + @Override + public boolean isCommitted() { + cleanupCalls++; + return true; + } + + @Override + public void resetBuffer() { + cleanupCalls++; + } + + @Override + public void flushBuffer() { + cleanupCalls++; + } + } + private static class TestableServlet extends JsonRpcServlet { void callDoPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { diff --git a/framework/src/test/java/org/tron/core/services/jsonrpc/TronJsonRpcImplChainIdentityTest.java b/framework/src/test/java/org/tron/core/services/jsonrpc/TronJsonRpcImplChainIdentityTest.java new file mode 100644 index 00000000000..93ba96ace05 --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/jsonrpc/TronJsonRpcImplChainIdentityTest.java @@ -0,0 +1,78 @@ +package org.tron.core.services.jsonrpc; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import org.junit.Assert; +import org.junit.Test; +import org.slf4j.LoggerFactory; +import org.tron.core.Wallet; +import org.tron.core.capsule.BlockCapsule; +import org.tron.core.exception.jsonrpc.JsonRpcInternalException; + +public class TronJsonRpcImplChainIdentityTest { + + @Test + public void testChainIdentityLogsFailureStateTransitions() throws Exception { + Wallet wallet = mock(Wallet.class); + BlockCapsule genesis = mock(BlockCapsule.class); + when(genesis.getBlockId()).thenReturn(new BlockCapsule.BlockId(new byte[32], 0)); + when(wallet.getBlockCapsuleByNum(0)) + .thenReturn(null) + .thenReturn(null) + .thenReturn(genesis) + .thenReturn(null); + + TronJsonRpcImpl rpc = new TronJsonRpcImpl(null, wallet); + Logger apiLogger = (Logger) LoggerFactory.getLogger("API"); + ListAppender appender = new ListAppender<>(); + appender.start(); + apiLogger.addAppender(appender); + + try { + Assert.assertThrows(JsonRpcInternalException.class, rpc::ethChainId); + Assert.assertThrows(JsonRpcInternalException.class, rpc::ethChainId); + Assert.assertEquals("0x00000000", rpc.ethChainId()); + Assert.assertThrows(JsonRpcInternalException.class, rpc::ethChainId); + + Assert.assertEquals(2, + countEvents(appender, Level.WARN, "Chain identity lookup failed")); + Assert.assertEquals(1, + countEvents(appender, Level.INFO, "Chain identity lookup recovered")); + + ILoggingEvent firstFailure = findEvent( + appender, Level.WARN, "Chain identity lookup failed"); + Assert.assertNotNull(firstFailure); + Assert.assertNotNull(firstFailure.getThrowableProxy()); + } finally { + apiLogger.detachAppender(appender); + appender.stop(); + rpc.close(); + } + } + + private static ILoggingEvent findEvent(ListAppender appender, Level level, + String marker) { + for (ILoggingEvent event : appender.list) { + if (event.getLevel() == level && event.getFormattedMessage().contains(marker)) { + return event; + } + } + return null; + } + + private static int countEvents(ListAppender appender, Level level, + String marker) { + int count = 0; + for (ILoggingEvent event : appender.list) { + if (event.getLevel() == level && event.getFormattedMessage().contains(marker)) { + count++; + } + } + return count; + } +} diff --git a/framework/src/test/java/org/tron/core/services/jsonrpc/filters/LogBlockQueryFailureTest.java b/framework/src/test/java/org/tron/core/services/jsonrpc/filters/LogBlockQueryFailureTest.java new file mode 100644 index 00000000000..b377ca415d4 --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/jsonrpc/filters/LogBlockQueryFailureTest.java @@ -0,0 +1,108 @@ +package org.tron.core.services.jsonrpc.filters; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.IThrowableProxy; +import ch.qos.logback.core.read.ListAppender; +import java.util.BitSet; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import org.junit.Assert; +import org.junit.Test; +import org.mockito.ArgumentMatchers; +import org.slf4j.LoggerFactory; +import org.tron.core.services.jsonrpc.TronJsonRpc.FilterRequest; +import org.tron.core.store.SectionBloomStore; + +public class LogBlockQueryFailureTest { + + private static final long CURRENT_MAX_BLOCK_NUM = 100L; + private static final String SENSITIVE_MARKER = "worker-sensitive-marker"; + + @Test + public void testExecutionFailureLogsCauseAndRethrowsWrapper() throws Exception { + NullPointerException cause = new NullPointerException(SENSITIVE_MARKER); + ExecutionException failure = new ExecutionException(cause); + Future future = futureThrowing(failure); + LogBlockQuery query = newQuery(future); + ListAppender appender = attachApiAppender(); + + try { + ExecutionException thrown = Assert.assertThrows(ExecutionException.class, + query::getPossibleBlock); + + Assert.assertSame(failure, thrown); + Assert.assertTrue(hasThrowable(appender, NullPointerException.class, SENSITIVE_MARKER)); + } finally { + detachApiAppender(appender); + } + } + + @Test + public void testInterruptionRestoresFlagAndRethrows() throws Exception { + Thread.interrupted(); + InterruptedException failure = new InterruptedException(); + Future future = futureThrowing(failure); + LogBlockQuery query = newQuery(future); + ListAppender appender = attachApiAppender(); + + try { + InterruptedException thrown = Assert.assertThrows(InterruptedException.class, + query::getPossibleBlock); + + Assert.assertSame(failure, thrown); + Assert.assertTrue(Thread.currentThread().isInterrupted()); + Assert.assertTrue(hasThrowable(appender, InterruptedException.class, null)); + } finally { + Thread.interrupted(); + detachApiAppender(appender); + } + } + + private static LogBlockQuery newQuery(Future future) throws Exception { + ExecutorService executor = mock(ExecutorService.class); + when(executor.submit(ArgumentMatchers.>any())).thenReturn(future); + SectionBloomStore store = mock(SectionBloomStore.class); + LogFilterWrapper wrapper = new LogFilterWrapper( + new FilterRequest("0x0", "0x1", + "0x1111111111111111111111111111111111111111", null, null), + CURRENT_MAX_BLOCK_NUM, null, false); + return new LogBlockQuery(wrapper, store, CURRENT_MAX_BLOCK_NUM, executor); + } + + @SuppressWarnings("unchecked") + private static Future futureThrowing(Exception failure) throws Exception { + Future future = mock(Future.class); + when(future.get()).thenThrow(failure); + return future; + } + + private static ListAppender attachApiAppender() { + ListAppender appender = new ListAppender<>(); + appender.start(); + ((Logger) LoggerFactory.getLogger("API")).addAppender(appender); + return appender; + } + + private static void detachApiAppender(ListAppender appender) { + ((Logger) LoggerFactory.getLogger("API")).detachAppender(appender); + appender.stop(); + } + + private static boolean hasThrowable(ListAppender appender, + Class type, String message) { + for (ILoggingEvent event : appender.list) { + IThrowableProxy throwable = event.getThrowableProxy(); + if (throwable != null && type.getName().equals(throwable.getClassName()) + && (message == null || message.equals(throwable.getMessage()))) { + return true; + } + } + return false; + } +}