This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new bc8de77c fix: batch studio bug fixes across trace, ACL, auth, alert
and SSRF (#1056)
bc8de77c is described below
commit bc8de77c58e5447e77a29ed6073cde02a0d4449d
Author: yyqdbngt <[email protected]>
AuthorDate: Thu Aug 6 14:26:37 2026 +0800
fix: batch studio bug fixes across trace, ACL, auth, alert and SSRF (#1056)
Co-authored-by: yyqdbngt <[email protected]>
---
.../rocketmq/studio/auth/AuthInterceptor.java | 35 ++++++++-
.../apache/rocketmq/studio/auth/AuthWebConfig.java | 22 ++++--
.../studio/instance/acl/AclController.java | 2 +-
.../rocketmq/studio/instance/acl/AclService.java | 4 +-
.../studio/instance/acl/UpdateAclUserDTO.java | 8 +-
.../apache/rocketmq/studio/ops/ai/AiService.java | 12 +++
.../rocketmq/studio/ops/ai/CliAgentProvider.java | 71 +++++++++++-------
.../rocketmq/studio/ops/alert/AlertService.java | 35 ++++++---
.../studio/rocketmq/RocketMQAdminClientImpl.java | 15 +++-
.../studio/rocketmq/RocketMQMessageProvider.java | 47 ++++++------
.../studio/rocketmq/RocketMQMetadataProvider.java | 4 +-
.../rocketmq/studio/settings/SettingsService.java | 33 ++++++++-
.../rocketmq/studio/auth/AuthControllerTest.java | 4 +
.../studio/auth/AuthCorsIntegrationTest.java | 4 +
.../rocketmq/studio/auth/AuthInterceptorTest.java | 85 ++++++++++++++++++++--
.../studio/instance/acl/AclControllerTest.java | 11 ++-
.../studio/instance/acl/AclServiceTest.java | 64 ++++++++++------
.../rocketmq/studio/ops/ai/AiServiceTest.java | 20 +++++
.../studio/ops/ai/CliAgentProviderTest.java | 66 +++++++++++++++++
.../studio/ops/alert/AlertServiceTest.java | 31 ++++++++
.../rocketmq/RocketMQAdminClientImplTest.java | 50 +++++++++++++
.../rocketmq/RocketMQMessageProviderTest.java | 68 +++++++++++++++++
.../rocketmq/RocketMQMetadataProviderTest.java | 79 ++++++++++++++++++++
.../studio/settings/SettingsServiceTest.java | 26 +++++++
24 files changed, 683 insertions(+), 113 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthInterceptor.java
b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthInterceptor.java
index be8aeff9..f642319b 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthInterceptor.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthInterceptor.java
@@ -20,6 +20,8 @@ package org.apache.rocketmq.studio.auth;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
+import org.apache.rocketmq.studio.settings.GeneralSettingsVO;
+import org.apache.rocketmq.studio.settings.SettingsRepository;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
@@ -37,17 +39,17 @@ public class AuthInterceptor implements HandlerInterceptor {
"/api/ai/chat",
"/api/clusters/test-connection",
"/api/llm/config/test",
- "/api/metrics/query",
- "/api/settings/datasources/test");
+ "/api/metrics/query");
private final AuthProperties authProperties;
private final AuthService authService;
+ private final SettingsRepository settingsRepository;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse
response,
Object handler) throws Exception {
AuthenticatedUserContext.clear();
- if (!authProperties.isLoginRequired() ||
CorsUtils.isPreFlightRequest(request)
+ if (!isLoginRequired() || CorsUtils.isPreFlightRequest(request)
|| isPublicPath(requestPath(request))) {
return true;
}
@@ -65,15 +67,40 @@ public class AuthInterceptor implements HandlerInterceptor {
return true;
}
+ /**
+ * Login enforcement comes from the static {@code
studio.auth.login-required} property OR the
+ * runtime "requireLogin" toggle persisted in the settings database, so
toggling it in the
+ * settings UI actually changes the enforced policy.
+ */
+ private boolean isLoginRequired() {
+ if (authProperties != null && authProperties.isLoginRequired()) {
+ return true;
+ }
+ if (settingsRepository == null) {
+ return false;
+ }
+ try {
+ GeneralSettingsVO settings =
settingsRepository.loadGeneralSettings();
+ return settings != null && settings.isRequireLogin();
+ } catch (Exception exception) {
+ return false;
+ }
+ }
+
private boolean requiresAdmin(HttpServletRequest request, String path) {
String method = request.getMethod();
if (HttpMethod.GET.matches(method) || HttpMethod.HEAD.matches(method)
|| HttpMethod.OPTIONS.matches(method)) {
- return false;
+ // Read endpoints stay open to readers, except credential views
that expose secrets.
+ return isAdminOnlyGetPath(path);
}
return !HttpMethod.POST.matches(method) ||
!READER_POST_PATHS.contains(normalizePath(path));
}
+ private boolean isAdminOnlyGetPath(String path) {
+ return path.startsWith("/api/acl/users/") &&
path.endsWith("/credentials");
+ }
+
private void writeError(HttpServletResponse response, HttpStatus status,
String message)
throws Exception {
response.setStatus(status.value());
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthWebConfig.java
b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthWebConfig.java
index c89d0485..f4b1aec4 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthWebConfig.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthWebConfig.java
@@ -18,21 +18,33 @@
package org.apache.rocketmq.studio.auth;
import lombok.RequiredArgsConstructor;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.apache.rocketmq.studio.settings.SettingsRepository;
+import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+/**
+ * The interceptor is always registered: enforcement itself decides whether
login is required,
+ * merging the static {@code studio.auth.login-required} property with the
runtime "requireLogin"
+ * toggle from the settings database. A conditional registration would make
the UI toggle a no-op
+ * whenever the static property is false.
+ */
@Configuration
@RequiredArgsConstructor
-@ConditionalOnProperty(prefix = "studio.auth", name = "login-required",
havingValue = "true")
public class AuthWebConfig implements WebMvcConfigurer {
- private final AuthProperties authProperties;
- private final AuthService authService;
+ private final ObjectProvider<AuthProperties> authPropertiesProvider;
+ private final ObjectProvider<AuthService> authServiceProvider;
+ private final ObjectProvider<SettingsRepository>
settingsRepositoryProvider;
@Override
public void addInterceptors(InterceptorRegistry registry) {
- registry.addInterceptor(new AuthInterceptor(authProperties,
authService)).addPathPatterns("/api/**");
+ // Slice tests and minimal contexts may not provide any of these
beans; when they are
+ // missing the interceptor falls back to the static login-required
property (effectively
+ // no enforcement), matching the old conditional-registration
behaviour.
+ registry.addInterceptor(new
AuthInterceptor(authPropertiesProvider.getIfAvailable(),
+ authServiceProvider.getIfAvailable(),
settingsRepositoryProvider.getIfAvailable()))
+ .addPathPatterns("/api/**");
}
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclController.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclController.java
index 39d3894f..4d25bf71 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclController.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclController.java
@@ -77,7 +77,7 @@ public class AclController {
@PostMapping("/users/update")
public Result<AclUserVO> updateUser(@Valid @RequestBody(required = false)
UpdateAclUserDTO user) {
- return Result.ok(aclService.updateUser(requireRequest(user, "ACL user
request is required").toAclUserVO()));
+ return Result.ok(aclService.updateUser(requireRequest(user, "ACL user
request is required")));
}
@PostMapping("/users/delete")
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclService.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclService.java
index a4772e1a..65c7995e 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclService.java
@@ -93,7 +93,7 @@ public class AclService {
return aclRepository.saveUser(user);
}
- public AclUserVO updateUser(AclUserVO user) {
+ public AclUserVO updateUser(UpdateAclUserDTO user) {
if (isBlank(user.getId())) {
throw new BusinessException(400, "ACL user id is required");
}
@@ -105,7 +105,7 @@ public class AclService {
.username(user.getUsername() == null ? existing.getUsername()
: user.getUsername())
.accessKey(existing.getAccessKey())
.secretKey(existing.getSecretKey())
- .admin(user.isAdmin())
+ .admin(user.getAdmin() == null ? existing.isAdmin() :
user.getAdmin())
.clusters(user.getClusters() == null ? existing.getClusters()
: user.getClusters())
.createdAt(existing.getCreatedAt())
.build();
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/UpdateAclUserDTO.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/UpdateAclUserDTO.java
index 19d19e00..52dd97fb 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/UpdateAclUserDTO.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/UpdateAclUserDTO.java
@@ -26,14 +26,18 @@ public class UpdateAclUserDTO {
@NotBlank(message = "id is required")
private String id;
private String username;
- private boolean admin;
+ /**
+ * Null when the admin flag was not part of the partial update, in which
case the existing
+ * value must be preserved instead of being reset to {@code false}.
+ */
+ private Boolean admin;
private List<String> clusters;
public AclUserVO toAclUserVO() {
return AclUserVO.builder()
.id(id)
.username(username)
- .admin(admin)
+ .admin(admin != null && admin)
.clusters(clusters)
.build();
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/AiService.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/AiService.java
index a4394535..00033143 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/AiService.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/AiService.java
@@ -18,6 +18,7 @@ package org.apache.rocketmq.studio.ops.ai;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
@@ -34,12 +35,23 @@ public class AiService {
public SseEmitter chat(ChatDTO request) {
+ if (request == null) {
+ log.warn("Chat request body is missing");
+ throw new BusinessException(400, "Chat request is required");
+ }
log.info("Chat request received: mode={}, conversationId={}",
request.getMode(), request.getConversationId());
return llmGateway.chat(request);
}
public AiExecuteResultVO execute(AiCommandDTO command) {
+ if (command == null) {
+ log.warn("AI command body is missing");
+ return AiExecuteResultVO.builder()
+ .success(false)
+ .result("Command request is required")
+ .build();
+ }
log.info("Executing AI command: {}", command.getCommand());
try {
String result = llmGateway.execute(command);
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/CliAgentProvider.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/CliAgentProvider.java
index ccec3ec4..47b37d0c 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/CliAgentProvider.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/CliAgentProvider.java
@@ -23,6 +23,7 @@ import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
+import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
/**
@@ -68,41 +69,61 @@ public abstract class CliAgentProvider implements
AgentProvider {
ProcessBuilder builder = new ProcessBuilder(command);
Map<String, String> env = builder.environment();
env.putAll(childEnv(config));
- builder.redirectErrorStream(false);
+ // Merge stderr into stdout and drain the stream on a background
thread. Reading stdout
+ // then stderr sequentially on the caller thread deadlocks once the
child fills a pipe
+ // buffer (64 KiB), and a timeout that runs only after both reads can
never fire while the
+ // child stays alive. With the merged stream drained in the
background, waitFor can enforce
+ // the timeout and a hung child is destroyed instead of leaking the
caller thread.
+ builder.redirectErrorStream(true);
+ Process process;
try {
- Process process = builder.start();
- String stdout = new
String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
- String stderr = new
String(process.getErrorStream().readAllBytes(), StandardCharsets.UTF_8);
- boolean finished = process.waitFor(TIMEOUT_SECONDS,
TimeUnit.SECONDS);
- if (!finished) {
- process.destroyForcibly();
- throw new LlmGatewayException(504, "llm.provider.timeout",
- binaryName() + " CLI timed out after " +
TIMEOUT_SECONDS + "s",
- "Retry with a shorter prompt or check the gateway
latency.");
- }
- if (process.exitValue() != 0) {
- log.warn("{} CLI failed rc={}, stderr={}", binaryName(),
process.exitValue(), abbreviate(stderr));
- throw new LlmGatewayException(502, "llm.provider.cli_error",
- binaryName() + " CLI failed: " + abbreviate(
- StringUtils.hasText(stderr) ? stderr : stdout),
- "Check the provider credentials, base URL and model
name.");
- }
- String result = stdout.trim();
- if (!StringUtils.hasText(result)) {
- throw new LlmGatewayException(502,
"llm.provider.empty_completion",
- binaryName() + " CLI returned an empty completion",
- "Check the selected model and provider response.");
- }
- return result;
+ process = builder.start();
} catch (IOException exception) {
throw new LlmGatewayException(502, "llm.provider.io_error",
"Failed to execute " + binaryName() + " CLI",
"Check that the CLI binary is installed and executable.",
exception);
+ }
+ CompletableFuture<String> outputFuture =
CompletableFuture.supplyAsync(() -> {
+ try (java.io.InputStream in = process.getInputStream()) {
+ return new String(in.readAllBytes(), StandardCharsets.UTF_8);
+ } catch (IOException ignored) {
+ return "";
+ }
+ });
+ boolean finished;
+ try {
+ finished = process.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS);
} catch (InterruptedException exception) {
+ process.destroyForcibly();
Thread.currentThread().interrupt();
throw new LlmGatewayException(502, "llm.provider.interrupted",
binaryName() + " CLI execution was interrupted", "Retry
the request.", exception);
}
+ String output;
+ try {
+ output = outputFuture.get(10, TimeUnit.SECONDS);
+ } catch (Exception exception) {
+ output = "";
+ }
+ if (!finished) {
+ process.destroyForcibly();
+ throw new LlmGatewayException(504, "llm.provider.timeout",
+ binaryName() + " CLI timed out after " + TIMEOUT_SECONDS +
"s",
+ "Retry with a shorter prompt or check the gateway
latency.");
+ }
+ if (process.exitValue() != 0) {
+ log.warn("{} CLI failed rc={}, output={}", binaryName(),
process.exitValue(), abbreviate(output));
+ throw new LlmGatewayException(502, "llm.provider.cli_error",
+ binaryName() + " CLI failed: " + abbreviate(output),
+ "Check the provider credentials, base URL and model
name.");
+ }
+ String result = output.trim();
+ if (!StringUtils.hasText(result)) {
+ throw new LlmGatewayException(502, "llm.provider.empty_completion",
+ binaryName() + " CLI returned an empty completion",
+ "Check the selected model and provider response.");
+ }
+ return result;
}
private String abbreviate(String value) {
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertService.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertService.java
index c9c7ea71..053bd567 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertService.java
@@ -21,7 +21,10 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
import java.util.UUID;
@Slf4j
@@ -47,20 +50,28 @@ public class AlertService {
StringBuilder yaml = new StringBuilder();
yaml.append("groups:\n");
int index = 1;
+ // Prometheus requires each group name to be unique, so rules sharing
a group must be
+ // emitted under a single " - name:" block instead of one block per
rule.
+ Map<String, List<PrometheusAlertRule>> rulesByGroup = new
LinkedHashMap<>();
for (PrometheusAlertRule rule : prometheusRules) {
- yaml.append(" - name: ").append(rule.group()).append('\n');
+ rulesByGroup.computeIfAbsent(rule.group(), key -> new
ArrayList<>()).add(rule);
+ }
+ for (Map.Entry<String, List<PrometheusAlertRule>> group :
rulesByGroup.entrySet()) {
+ yaml.append(" - name: ").append(group.getKey()).append('\n');
yaml.append(" rules:\n");
- yaml.append(" # Rule ").append(index++).append(":
").append(rule.alert()).append('\n');
- yaml.append(" - alert: ").append(rule.alert()).append('\n');
- yaml.append(" expr: ").append(rule.expr()).append('\n');
- yaml.append(" for: ").append(rule.duration()).append('\n');
- yaml.append(" labels:\n");
- yaml.append(" severity:
").append(rule.severity()).append('\n');
- yaml.append(" team: ").append(rule.team()).append('\n');
- yaml.append(" annotations:\n");
- yaml.append(" summary:
\"").append(escapeDoubleQuotedValue(rule.summary())).append("\"\n");
- yaml.append(" description:
\"").append(escapeDoubleQuotedValue(rule.description()))
- .append("\"\n");
+ for (PrometheusAlertRule rule : group.getValue()) {
+ yaml.append(" # Rule ").append(index++).append(":
").append(rule.alert()).append('\n');
+ yaml.append(" - alert:
").append(rule.alert()).append('\n');
+ yaml.append(" expr: ").append(rule.expr()).append('\n');
+ yaml.append(" for:
").append(rule.duration()).append('\n');
+ yaml.append(" labels:\n");
+ yaml.append(" severity:
").append(rule.severity()).append('\n');
+ yaml.append(" team:
").append(rule.team()).append('\n');
+ yaml.append(" annotations:\n");
+ yaml.append(" summary:
\"").append(escapeDoubleQuotedValue(rule.summary())).append("\"\n");
+ yaml.append(" description:
\"").append(escapeDoubleQuotedValue(rule.description()))
+ .append("\"\n");
+ }
}
return yaml.toString();
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/rocketmq/RocketMQAdminClientImpl.java
b/server/src/main/java/org/apache/rocketmq/studio/rocketmq/RocketMQAdminClientImpl.java
index 1706d2ed..f6d4e72b 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/rocketmq/RocketMQAdminClientImpl.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/rocketmq/RocketMQAdminClientImpl.java
@@ -149,8 +149,13 @@ public class RocketMQAdminClientImpl implements
AdminClient {
int readQueues = topic.getReadQueues() > 0 ? topic.getReadQueues() : 8;
try {
+ String clusterName = getClusterName();
+ // Match on the (cluster_id, name) key: the same topic name can
exist in several
+ // clusters, and a name-only lookup would blow up with
TooManyResultsException.
RmqTopic existing = topicMapper.selectOne(
- new LambdaQueryWrapper<RmqTopic>().eq(RmqTopic::getName,
topicName));
+ new LambdaQueryWrapper<RmqTopic>()
+ .eq(RmqTopic::getClusterId, clusterName)
+ .eq(RmqTopic::getName, topicName));
TopicPerm effectivePerm = topic.getPerm() != null
? topic.getPerm()
: existing == null ? TopicPerm.RW :
fromRocketMQPerm(existing.getPerm());
@@ -172,7 +177,6 @@ public class RocketMQAdminClientImpl implements AdminClient
{
// Persist to DB. Re-creating a topic that already has a record
(for example when
// rebuilding a broker route from the console) must update it
instead of failing on
// the unique (cluster_id, name) key.
- String clusterName = getClusterName();
RmqTopic entity = topicMapper.selectOne(new
LambdaQueryWrapper<RmqTopic>()
.eq(RmqTopic::getClusterId, clusterName)
.eq(RmqTopic::getName, topicName));
@@ -228,8 +232,13 @@ public class RocketMQAdminClientImpl implements
AdminClient {
int readQueues = topic.getReadQueues() > 0 ? topic.getReadQueues() : 8;
try {
+ // Match on the (cluster_id, name) key to avoid ambiguity when
several clusters share
+ // the same topic name (a name-only lookup would throw
TooManyResultsException).
+ String clusterName = getClusterName();
RmqTopic existing = topicMapper.selectOne(
- new LambdaQueryWrapper<RmqTopic>().eq(RmqTopic::getName,
topicName));
+ new LambdaQueryWrapper<RmqTopic>()
+ .eq(RmqTopic::getClusterId, clusterName)
+ .eq(RmqTopic::getName, topicName));
TopicPerm effectivePerm = topic.getPerm() != null
? topic.getPerm()
: existing == null ? TopicPerm.RW :
fromRocketMQPerm(existing.getPerm());
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/rocketmq/RocketMQMessageProvider.java
b/server/src/main/java/org/apache/rocketmq/studio/rocketmq/RocketMQMessageProvider.java
index c8626813..1f2bac1e 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/rocketmq/RocketMQMessageProvider.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/rocketmq/RocketMQMessageProvider.java
@@ -286,11 +286,15 @@ public class RocketMQMessageProvider implements
MessageProvider {
if (fields.length == 0) {
continue;
}
- if (!targetMsgId.equals(field(fields, 5))) {
+ String traceType = fields[0].trim();
+ // The message id column differs by trace type: Pub/EndTransaction
encode the trace bean
+ // (msgId at index 5), while SubAfter in RocketMQ 5.3.3 places
msgId at index 2.
+ int msgIdIndex = "SubAfter".equals(traceType) ? 2 : 5;
+ if (!targetMsgId.equals(field(fields, msgIdIndex))) {
continue;
}
try {
- switch (fields[0].trim()) {
+ switch (traceType) {
case "Pub":
nodes.add(buildProduceNode(fields));
break;
@@ -311,49 +315,50 @@ public class RocketMQMessageProvider implements
MessageProvider {
}
}
- // Pub layout: type, time, region, group, topic, msgId, tags, keys,
storeHost, clientHost,
- // retryTimes, msgType, status, costTime
+ // Pub layout (RocketMQ 5.3.3 TraceDataEncoder): type, time, region,
group, topic, msgId,
+ // tags, keys, storeHost, bodyLength, costTime, msgType,
offsetMsgId, isSuccess
private TraceNodeVO buildProduceNode(String[] f) {
return TraceNodeVO.builder()
.title("produce")
.timestamp(parseLong(field(f, 1)))
- .status(parseBoolean(field(f, 12)) ? "finish" : "failed")
- .costTime(parseLong(field(f, 13)))
- .description("producer=" + field(f, 3) + ", clientHost=" +
field(f, 9)
- + ", storeHost=" + field(f, 8))
+ .status(parseBoolean(field(f, 13)) ? "finish" : "failed")
+ .costTime(parseLong(field(f, 10)))
+ .description("producer=" + field(f, 3) + ", storeHost=" +
field(f, 8))
.build();
}
- // SubAfter layout: type, time, region, group, traceId, msgId, retryTimes,
keys, costTime,
- // status, timestamp
+ // SubAfter layout (RocketMQ 5.3.3 TraceDataEncoder): type, requestId,
msgId, costTime,
+ // isSuccess, keys, contextCode, timeStamp, groupName. The
trailing
+ // timeStamp/groupName columns may be absent when the trace
has no region info,
+ // so lookups tolerate short lines.
private TraceNodeVO buildConsumeNode(String[] f) {
return TraceNodeVO.builder()
.title("consume")
- .timestamp(parseLong(field(f, 1)))
- .status(parseBoolean(field(f, 9)) ? "finish" : "failed")
- .costTime(parseLong(field(f, 8)))
- .description("group=" + field(f, 3) + ", retryTimes=" +
field(f, 6))
+ .timestamp(parseLong(field(f, 7)))
+ .status(parseBoolean(field(f, 4)) ? "finish" : "failed")
+ .costTime(parseLong(field(f, 3)))
+ .description("group=" + field(f, 8) + ", contextCode=" +
field(f, 6))
.build();
}
private ConsumerStatusVO buildConsumerStatus(String[] f) {
return ConsumerStatusVO.builder()
- .group(field(f, 3))
- .deliveryStatus(parseBoolean(field(f, 9)) ?
DeliveryStatus.success : DeliveryStatus.failed)
- .consumeTime(parseLong(field(f, 1)))
- .retryCount((int) parseLong(field(f, 6)))
+ .group(field(f, 8))
+ .deliveryStatus(parseBoolean(field(f, 4)) ?
DeliveryStatus.success : DeliveryStatus.failed)
+ .consumeTime(parseLong(field(f, 7)))
+ .retryCount(0)
.build();
}
- // EndTransaction layout: type, time, region, group, topic, msgId, tags,
keys, storeHost,
- // clientHost, retryTimes, msgType, status,
transactionId, txState
+ // EndTransaction layout (RocketMQ 5.3.3): type, time, region, group,
topic, msgId, tags,
+ // keys, storeHost, bodyLength, costTime, msgType,
transactionId, txState
private TraceNodeVO buildTransactionNode(String[] f) {
return TraceNodeVO.builder()
.title("endTransaction")
.timestamp(parseLong(field(f, 1)))
.status("finish")
.costTime(0L)
- .description("group=" + field(f, 3) + ", transactionState=" +
field(f, 14))
+ .description("group=" + field(f, 3) + ", transactionState=" +
field(f, 13))
.build();
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/rocketmq/RocketMQMetadataProvider.java
b/server/src/main/java/org/apache/rocketmq/studio/rocketmq/RocketMQMetadataProvider.java
index fe8b2c5d..75f83344 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/rocketmq/RocketMQMetadataProvider.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/rocketmq/RocketMQMetadataProvider.java
@@ -166,7 +166,9 @@ public class RocketMQMetadataProvider implements
MetadataProvider {
vo.setName(entity.getName());
vo.setClusterId(entity.getClusterId());
vo.setInstanceId(entity.getInstanceId());
- vo.setConsumeType(parseConsumeType(entity.getMessageModel()));
+ // consumeType stores the real ConsumeType
("CLUSTERING"/"BROADCASTING"); messageModel
+ // holds the subscription mode ("Push"/"Pop") and is not a
ConsumeType.
+ vo.setConsumeType(parseConsumeType(entity.getConsumeType()));
vo.setRetryMaxTimes(entity.getMaxRetry() == null ? 0 :
entity.getMaxRetry());
vo.setCreatedAt(entity.getCreatedAt());
vo.setUpdatedAt(entity.getUpdatedAt());
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsService.java
b/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsService.java
index ceda5b7c..2b5ec081 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsService.java
@@ -33,11 +33,14 @@ import
org.springframework.web.client.RestClientResponseException;
import org.springframework.web.util.UriComponentsBuilder;
import java.io.IOException;
+import java.net.InetAddress;
+import java.net.SocketTimeoutException;
import java.net.URI;
import java.net.URISyntaxException;
-import java.net.SocketTimeoutException;
+import java.net.UnknownHostException;
import java.time.Duration;
import java.util.List;
+import java.util.Locale;
import java.util.Set;
import java.util.UUID;
@@ -227,12 +230,40 @@ public class SettingsService {
if (!"http".equalsIgnoreCase(baseUri.getScheme()) &&
!"https".equalsIgnoreCase(baseUri.getScheme())) {
throw new IllegalArgumentException("Data source URL must start
with http:// or https://");
}
+ if (!isAllowedDataSourceHost(baseUri.getHost())) {
+ throw new IllegalArgumentException(
+ "Data source URL must not point to a local or private
address");
+ }
return UriComponentsBuilder.fromUriString(normalized + "/api/v1/query")
.queryParam("query", PROMETHEUS_TEST_QUERY)
.build()
.toUri();
}
+ /**
+ * SSRF guard: the test endpoint performs a server-side HTTP request to an
attacker-supplied
+ * URL. The hostname {@code localhost} and link-local addresses
(169.254.x.x, fe80:: — the
+ * cloud metadata range) are never legitimate Prometheus endpoints and are
rejected. Loopback
+ * IPs and private site-local ranges stay allowed because on-premise
Prometheus servers live
+ * on the internal network and the endpoint itself requires admin rights.
+ */
+ private boolean isAllowedDataSourceHost(String host) {
+ if (!StringUtils.hasText(host)) {
+ return false;
+ }
+ String normalized = host.toLowerCase(Locale.ROOT);
+ if ("localhost".equals(normalized)) {
+ return false;
+ }
+ try {
+ InetAddress address = InetAddress.getByName(normalized);
+ return !address.isAnyLocalAddress() &&
!address.isLinkLocalAddress();
+ } catch (UnknownHostException exception) {
+ // Unresolvable host: let the connection attempt surface the real
connectivity error.
+ return true;
+ }
+ }
+
private DataSourceTestResultVO prometheusSuccess(JsonNode response) {
if (response != null &&
"success".equals(response.path("status").asText())) {
return DataSourceTestResultVO.builder()
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthControllerTest.java
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthControllerTest.java
index bed876d6..44d0fa93 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthControllerTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthControllerTest.java
@@ -18,6 +18,7 @@
package org.apache.rocketmq.studio.auth;
import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.rocketmq.studio.settings.SettingsRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import
org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
@@ -52,6 +53,9 @@ class AuthControllerTest {
@MockBean
private AuthProperties authProperties;
+ @MockBean
+ private SettingsRepository settingsRepository;
+
@Autowired
private ObjectMapper objectMapper;
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthCorsIntegrationTest.java
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthCorsIntegrationTest.java
index 0d5701ae..5d02aba0 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthCorsIntegrationTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthCorsIntegrationTest.java
@@ -20,6 +20,7 @@ package org.apache.rocketmq.studio.auth;
import org.apache.rocketmq.studio.common.config.CorsConfig;
import org.apache.rocketmq.studio.instance.InstanceController;
import org.apache.rocketmq.studio.instance.InstanceService;
+import org.apache.rocketmq.studio.settings.SettingsRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -60,6 +61,9 @@ class AuthCorsIntegrationTest {
@MockBean
private AuthService authService;
+ @MockBean
+ private SettingsRepository settingsRepository;
+
@BeforeEach
void enableLoginProtection() {
when(authProperties.isLoginRequired()).thenReturn(true);
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthInterceptorTest.java
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthInterceptorTest.java
index d1f77071..9f2467e5 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthInterceptorTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthInterceptorTest.java
@@ -41,7 +41,7 @@ class AuthInterceptorTest {
@Test
void shouldAllowRequestsWhenLoginIsDisabled() throws Exception {
AuthProperties properties = new AuthProperties();
- AuthInterceptor interceptor = new AuthInterceptor(properties,
authService(properties));
+ AuthInterceptor interceptor = new AuthInterceptor(properties,
authService(properties), settingsRepository());
MockHttpServletRequest request = new MockHttpServletRequest("GET",
"/api/clusters");
boolean allowed = interceptor.preHandle(request, new
MockHttpServletResponse(), new Object());
@@ -55,7 +55,7 @@ class AuthInterceptorTest {
void shouldRejectProtectedApiWithoutTokenWhenLoginIsEnabled() throws
Exception {
AuthProperties properties = new AuthProperties();
properties.setLoginRequired(true);
- AuthInterceptor interceptor = new AuthInterceptor(properties,
authService(properties));
+ AuthInterceptor interceptor = new AuthInterceptor(properties,
authService(properties), settingsRepository());
MockHttpServletRequest request = new MockHttpServletRequest("GET",
"/api/clusters");
MockHttpServletResponse response = new MockHttpServletResponse();
@@ -76,7 +76,7 @@ class AuthInterceptorTest {
user.setAdmin(true);
properties.setUsers(List.of(user));
AuthService authService = authService(properties);
- AuthInterceptor interceptor = new AuthInterceptor(properties,
authService);
+ AuthInterceptor interceptor = new AuthInterceptor(properties,
authService, settingsRepository());
LoginDTO login = new LoginDTO();
login.setUsername("admin");
login.setPassword("secret");
@@ -97,11 +97,25 @@ class AuthInterceptorTest {
.isEqualTo(AuthenticatedUserContext.SYSTEM_ACTOR);
}
+ @Test
+ void shouldEnforceLoginWhenDatabaseRequiresItEvenIfPropertyIsDisabled()
throws Exception {
+ AuthProperties properties = new AuthProperties();
+ AuthInterceptor interceptor = new AuthInterceptor(properties,
authService(properties),
+ settingsRepositoryRequiringLogin());
+ MockHttpServletRequest request = new MockHttpServletRequest("GET",
"/api/clusters");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ boolean allowed = interceptor.preHandle(request, response, new
Object());
+
+ assertThat(allowed).isFalse();
+ assertThat(response.getStatus()).isEqualTo(401);
+ }
+
@Test
void shouldAllowLoginEndpointWhenLoginIsEnabled() throws Exception {
AuthProperties properties = new AuthProperties();
properties.setLoginRequired(true);
- AuthInterceptor interceptor = new AuthInterceptor(properties,
authService(properties));
+ AuthInterceptor interceptor = new AuthInterceptor(properties,
authService(properties), settingsRepository());
MockHttpServletRequest request = new MockHttpServletRequest("POST",
"/api/auth/login");
boolean allowed = interceptor.preHandle(request, new
MockHttpServletResponse(), new Object());
@@ -113,7 +127,7 @@ class AuthInterceptorTest {
void shouldAllowLoginEndpointWithTrailingSlashWhenLoginIsEnabled() throws
Exception {
AuthProperties properties = new AuthProperties();
properties.setLoginRequired(true);
- AuthInterceptor interceptor = new AuthInterceptor(properties,
authService(properties));
+ AuthInterceptor interceptor = new AuthInterceptor(properties,
authService(properties), settingsRepository());
MockHttpServletRequest request = new MockHttpServletRequest("POST",
"/api/auth/login/");
boolean allowed = interceptor.preHandle(request, new
MockHttpServletResponse(), new Object());
@@ -125,7 +139,7 @@ class AuthInterceptorTest {
void shouldAllowAuthStatusEndpointWhenLoginIsEnabled() throws Exception {
AuthProperties properties = new AuthProperties();
properties.setLoginRequired(true);
- AuthInterceptor interceptor = new AuthInterceptor(properties,
authService(properties));
+ AuthInterceptor interceptor = new AuthInterceptor(properties,
authService(properties), settingsRepository());
MockHttpServletRequest request = new MockHttpServletRequest("GET",
"/api/auth/status");
boolean allowed = interceptor.preHandle(request, new
MockHttpServletResponse(), new Object());
@@ -137,7 +151,7 @@ class AuthInterceptorTest {
void shouldAllowAuthStatusEndpointWithTrailingSlashWhenLoginIsEnabled()
throws Exception {
AuthProperties properties = new AuthProperties();
properties.setLoginRequired(true);
- AuthInterceptor interceptor = new AuthInterceptor(properties,
authService(properties));
+ AuthInterceptor interceptor = new AuthInterceptor(properties,
authService(properties), settingsRepository());
MockHttpServletRequest request = new MockHttpServletRequest("GET",
"/api/auth/status/");
boolean allowed = interceptor.preHandle(request, new
MockHttpServletResponse(), new Object());
@@ -157,6 +171,22 @@ class AuthInterceptorTest {
return settingsRepository;
}
+ private SettingsRepository settingsRepository() {
+ SettingsRepository settingsRepository = mock(SettingsRepository.class);
+
when(settingsRepository.loadGeneralSettings()).thenReturn(GeneralSettingsVO.builder()
+ .requireLogin(false)
+ .build());
+ return settingsRepository;
+ }
+
+ private SettingsRepository settingsRepositoryRequiringLogin() {
+ SettingsRepository settingsRepository = mock(SettingsRepository.class);
+
when(settingsRepository.loadGeneralSettings()).thenReturn(GeneralSettingsVO.builder()
+ .requireLogin(true)
+ .build());
+ return settingsRepository;
+ }
+
@Test
void shouldAllowReadOnlyGetForNonAdminUser() throws Exception {
TestSession session = login(false);
@@ -207,6 +237,19 @@ class AuthInterceptorTest {
assertThat(allowed).isTrue();
}
+ @Test
+ void shouldRejectDataSourceTestForNonAdminUser() throws Exception {
+ TestSession session = login(false);
+ MockHttpServletRequest request = authenticatedRequest(
+ "POST", "/api/settings/datasources/test", session.token());
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ boolean allowed = session.interceptor().preHandle(request, response,
new Object());
+
+ assertThat(allowed).isFalse();
+ assertThat(response.getStatus()).isEqualTo(403);
+ }
+
@Test
void shouldAllowLogoutForNonAdminUser() throws Exception {
TestSession session = login(false);
@@ -219,6 +262,32 @@ class AuthInterceptorTest {
assertThat(allowed).isTrue();
}
+ @Test
+ void shouldRejectCredentialsViewForNonAdminUser() throws Exception {
+ TestSession session = login(false);
+ MockHttpServletRequest request = authenticatedRequest(
+ "GET", "/api/acl/users/user-1/credentials", session.token());
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ boolean allowed = session.interceptor().preHandle(request, response,
new Object());
+
+ assertThat(allowed).isFalse();
+ assertThat(response.getStatus()).isEqualTo(403);
+ assertThat(response.getContentAsString()).contains("Admin permission
required");
+ }
+
+ @Test
+ void shouldAllowCredentialsViewForAdminUser() throws Exception {
+ TestSession session = login(true);
+ MockHttpServletRequest request = authenticatedRequest(
+ "GET", "/api/acl/users/user-1/credentials", session.token());
+
+ boolean allowed = session.interceptor().preHandle(
+ request, new MockHttpServletResponse(), new Object());
+
+ assertThat(allowed).isTrue();
+ }
+
private TestSession login(boolean admin) {
AuthProperties properties = new AuthProperties();
properties.setLoginRequired(true);
@@ -232,7 +301,7 @@ class AuthInterceptorTest {
login.setUsername("test-user");
login.setPassword("secret");
String token = authService.login(login).getToken();
- return new TestSession(new AuthInterceptor(properties, authService),
token);
+ return new TestSession(new AuthInterceptor(properties, authService,
settingsRepository()), token);
}
private MockHttpServletRequest authenticatedRequest(String method, String
path, String token) {
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclControllerTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclControllerTest.java
index 8e66a1a2..f987ff9e 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclControllerTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclControllerTest.java
@@ -284,11 +284,10 @@ class AclControllerTest {
@Test
void updateUserShouldReturnMaskedUpdatedUser() throws Exception {
- AclUserVO input = AclUserVO.builder()
- .id("user-1")
- .username("admin")
- .admin(false)
- .build();
+ UpdateAclUserDTO input = new UpdateAclUserDTO();
+ input.setId("user-1");
+ input.setUsername("admin");
+ input.setAdmin(false);
AclUserVO updated = AclUserVO.builder()
.id("user-1")
.username("admin")
@@ -297,7 +296,7 @@ class AclControllerTest {
.admin(false)
.build();
- when(aclService.updateUser(any(AclUserVO.class))).thenReturn(updated);
+
when(aclService.updateUser(any(UpdateAclUserDTO.class))).thenReturn(updated);
mockMvc.perform(post("/api/acl/users/update")
.contentType(MediaType.APPLICATION_JSON)
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclServiceTest.java
index 739aa811..0a78fbcc 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclServiceTest.java
@@ -337,9 +337,8 @@ class AclServiceTest {
@Test
void updateUserShouldRequireId() {
- AclUserVO input = AclUserVO.builder()
- .username("newuser")
- .build();
+ UpdateAclUserDTO input = new UpdateAclUserDTO();
+ input.setUsername("newuser");
assertThatThrownBy(() -> aclService.updateUser(input))
.hasMessage("ACL user id is required");
@@ -347,13 +346,10 @@ class AclServiceTest {
@Test
void updateUserShouldSaveExistingUser() {
- AclUserVO input = AclUserVO.builder()
- .id("user-1")
- .username("newuser")
- .accessKey("client-access-key")
- .secretKey("client-secret-key")
- .admin(true)
- .build();
+ UpdateAclUserDTO input = new UpdateAclUserDTO();
+ input.setId("user-1");
+ input.setUsername("newuser");
+ input.setAdmin(true);
ArgumentCaptor<AclUserVO> captor =
ArgumentCaptor.forClass(AclUserVO.class);
when(aclRepository.findUserById("user-1")).thenReturn(Optional.of(existingUser));
@@ -372,11 +368,37 @@ class AclServiceTest {
}
@Test
- void updateUserShouldThrowWhenUserDoesNotExist() {
- AclUserVO input = AclUserVO.builder()
- .id("missing")
- .username("ghost")
+ void updateUserShouldPreserveAdminWhenNotProvided() {
+ AclUserVO adminUser = AclUserVO.builder()
+ .id("user-1")
+ .username("orders")
+ .accessKey("access-key-123456")
+ .secretKey("secret-key-987654")
+ .admin(true)
+ .clusters(List.of("cluster-a"))
.build();
+ UpdateAclUserDTO input = new UpdateAclUserDTO();
+ input.setId("user-1");
+ input.setUsername("renamed");
+ // admin intentionally left null: the existing admin flag must survive
the partial update.
+
+ ArgumentCaptor<AclUserVO> captor =
ArgumentCaptor.forClass(AclUserVO.class);
+
when(aclRepository.findUserById("user-1")).thenReturn(Optional.of(adminUser));
+ when(aclRepository.saveUser(any(AclUserVO.class))).thenAnswer(inv ->
inv.getArgument(0));
+
+ AclUserVO result = aclService.updateUser(input);
+
+ assertThat(result.getUsername()).isEqualTo("renamed");
+ assertThat(result.isAdmin()).isTrue();
+ verify(aclRepository).saveUser(captor.capture());
+ assertThat(captor.getValue().isAdmin()).isTrue();
+ }
+
+ @Test
+ void updateUserShouldThrowWhenUserDoesNotExist() {
+ UpdateAclUserDTO input = new UpdateAclUserDTO();
+ input.setId("missing");
+ input.setUsername("ghost");
when(aclRepository.findUserById("missing")).thenReturn(Optional.empty());
@@ -448,14 +470,12 @@ class AclServiceTest {
String secretKey = created.getSecretKey();
AclUserVO listed = aclService.listUsers().get(0);
- AclUserVO updated = aclService.updateUser(AclUserVO.builder()
- .id(listed.getId())
- .username("orders-admin")
- .accessKey(listed.getAccessKey())
- .secretKey(listed.getSecretKey())
- .admin(true)
- .clusters(listed.getClusters())
- .build());
+ UpdateAclUserDTO update = new UpdateAclUserDTO();
+ update.setId(listed.getId());
+ update.setUsername("orders-admin");
+ update.setAdmin(true);
+ update.setClusters(listed.getClusters());
+ AclUserVO updated = aclService.updateUser(update);
assertThat(listed.getAccessKey()).isNotEqualTo(accessKey);
assertThat(listed.getSecretKey()).isNotEqualTo(secretKey);
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/AiServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/AiServiceTest.java
index a10c2a8c..d98a751f 100644
--- a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/AiServiceTest.java
+++ b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/AiServiceTest.java
@@ -16,6 +16,7 @@
*/
package org.apache.rocketmq.studio.ops.ai;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
@@ -30,8 +31,10 @@ import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@@ -203,4 +206,21 @@ class AiServiceTest {
assertThat(result).isSameAs(output);
verify(mcpServerRegistry).execute("rmq.capabilities", input);
}
+
+ @Test
+ void chatRejectsNullRequest() {
+ assertThatThrownBy(() -> aiService.chat(null))
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("Chat request is required");
+ verifyNoInteractions(llmGateway);
+ }
+
+ @Test
+ void executeHandlesNullCommand() {
+ AiExecuteResultVO result = aiService.execute(null);
+
+ assertThat(result.isSuccess()).isFalse();
+ assertThat(result.getResult()).isEqualTo("Command request is
required");
+ verifyNoInteractions(llmGateway);
+ }
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/CliAgentProviderTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/CliAgentProviderTest.java
new file mode 100644
index 00000000..9a1fcdcd
--- /dev/null
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/CliAgentProviderTest.java
@@ -0,0 +1,66 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package org.apache.rocketmq.studio.ops.ai;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class CliAgentProviderTest {
+
+ private static final class FakeCli extends CliAgentProvider {
+ private final String script;
+
+ FakeCli(String script) {
+ this.script = script;
+ }
+
+ @Override
+ public String engine() {
+ return "fake";
+ }
+
+ @Override
+ protected List<String> buildCommand(LlmConfigVO config, String prompt,
String modelOverride) {
+ return List.of("sh", "-c", script);
+ }
+
+ @Override
+ protected Map<String, String> childEnv(LlmConfigVO config) {
+ return Map.of();
+ }
+
+ @Override
+ protected String binaryName() {
+ return "sh";
+ }
+ }
+
+ @Test
+ void completeSurvivesLargeStderrOutput() {
+ // Write well over the 64 KiB pipe buffer to stderr, then print the
completion on stdout.
+ // Before the fix, sequential stdout/stderr reads deadlocked the
caller forever.
+ String script = "for i in $(seq 1 10000); do "
+ + "echo 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
>&2; done; "
+ + "echo DONE";
+ FakeCli cli = new FakeCli(script);
+
+ String result = cli.complete(null, "prompt", null);
+
+ assertThat(result).contains("DONE");
+ // All stderr bytes were drained as well (merged into the single
output stream),
+ // well past the 64 KiB pipe buffer that used to deadlock the
sequential reads.
+ assertThat(result.length()).isGreaterThan(500_000);
+ }
+}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceTest.java
index 6ca3eba6..35a46555 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceTest.java
@@ -113,6 +113,37 @@ class AlertServiceTest {
.contains("description: \"Lag too high\"");
}
+ @Test
+ void exportPrometheusRulesYamlShouldEmitSingleGroupForSameTeamRules() {
+ AlertRuleVO first = AlertRuleVO.builder()
+ .name("Lag Alert A")
+ .metric("rocketmq_consumer_lag_messages")
+ .operator(">")
+ .threshold(1000)
+ .duration("3m")
+ .description("First lag alert")
+ .build();
+ AlertRuleVO second = AlertRuleVO.builder()
+ .name("Lag Alert B")
+ .metric("rocketmq_consumer_lag_messages")
+ .operator(">")
+ .threshold(2000)
+ .duration("5m")
+ .description("Second lag alert")
+ .build();
+ when(alertRepository.findAllRules()).thenReturn(List.of(first,
second));
+
+ String result = alertService.exportPrometheusRulesYaml();
+
+ long groupOccurrences = result.split("- name:
rocketmq-consumer.rules", -1).length - 1;
+ assertThat(groupOccurrences).isEqualTo(1);
+ assertThat(result)
+ .contains("# Rule 1: LagAlertA")
+ .contains("# Rule 2: LagAlertB")
+ .contains("expr: rocketmq_consumer_lag_messages > 1000")
+ .contains("expr: rocketmq_consumer_lag_messages > 2000");
+ }
+
@Test
void
exportPrometheusRulesYamlShouldRenderReplicationLagRuleWithScopeAndSeverity() {
AlertRuleVO rule = AlertRuleVO.builder()
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/rocketmq/RocketMQAdminClientImplTest.java
b/server/src/test/java/org/apache/rocketmq/studio/rocketmq/RocketMQAdminClientImplTest.java
index 3d5ca7cb..46a0f3d0 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/rocketmq/RocketMQAdminClientImplTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/rocketmq/RocketMQAdminClientImplTest.java
@@ -10,24 +10,45 @@
*/
package org.apache.rocketmq.studio.rocketmq;
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.apache.rocketmq.client.exception.MQBrokerException;
import org.apache.rocketmq.client.exception.MQClientException;
+import org.apache.rocketmq.common.TopicConfig;
import org.apache.rocketmq.remoting.exception.RemotingTimeoutException;
import org.apache.rocketmq.remoting.protocol.ResponseCode;
+import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
+import org.apache.rocketmq.remoting.protocol.route.BrokerData;
import org.apache.rocketmq.studio.common.exception.BusinessException;
import org.apache.rocketmq.studio.instance.group.ConsumerGroupVO;
+import org.apache.rocketmq.studio.instance.topic.TopicVO;
import org.apache.rocketmq.studio.ops.audit.AuditService;
+import org.apache.rocketmq.studio.persistence.entity.RmqTopic;
import org.apache.rocketmq.studio.persistence.mapper.RmqGroupMapper;
import org.apache.rocketmq.studio.persistence.mapper.RmqTopicMapper;
import org.apache.rocketmq.tools.admin.DefaultMQAdminExt;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@@ -82,4 +103,33 @@ class RocketMQAdminClientImplTest {
.isInstanceOf(BusinessException.class)
.hasMessageContaining("ACL denied");
}
+
+ @Test
+ void createTopicScopesLookupToCluster() throws Exception {
+ TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new
MybatisConfiguration(), ""), RmqTopic.class);
+ ClusterInfo clusterInfo = new ClusterInfo();
+ Map<String, Set<String>> clusterAddrTable = new HashMap<>();
+ clusterAddrTable.put("cluster-1", new HashSet<>(List.of("broker-1")));
+ clusterInfo.setClusterAddrTable(clusterAddrTable);
+ Map<String, BrokerData> brokerAddrTable = new HashMap<>();
+ BrokerData brokerData = new BrokerData();
+ brokerData.setBrokerName("broker-1");
+ brokerData.setBrokerAddrs(new HashMap<>(Map.of(0L, "10.0.0.1:10911")));
+ brokerAddrTable.put("broker-1", brokerData);
+ clusterInfo.setBrokerAddrTable(brokerAddrTable);
+ when(adminExt.examineBrokerClusterInfo()).thenReturn(clusterInfo);
+ when(topicMapper.selectOne(any())).thenReturn(null);
+ doNothing().when(adminExt).createAndUpdateTopicConfig(anyString(),
any(TopicConfig.class));
+
+ TopicVO topic = new TopicVO();
+ topic.setName("topicA");
+ adminClient.createTopic(topic);
+
+ ArgumentCaptor<LambdaQueryWrapper<RmqTopic>> captor =
+ ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+ verify(topicMapper, times(2)).selectOne(captor.capture());
+ for (LambdaQueryWrapper<RmqTopic> wrapper : captor.getAllValues()) {
+ assertThat(wrapper.getSqlSegment()).contains("cluster_id");
+ }
+ }
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/rocketmq/RocketMQMessageProviderTest.java
b/server/src/test/java/org/apache/rocketmq/studio/rocketmq/RocketMQMessageProviderTest.java
index 40520901..e8b783b1 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/rocketmq/RocketMQMessageProviderTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/rocketmq/RocketMQMessageProviderTest.java
@@ -16,10 +16,14 @@
*/
package org.apache.rocketmq.studio.rocketmq;
+import org.apache.rocketmq.client.QueryResult;
import org.apache.rocketmq.client.consumer.DefaultMQPullConsumer;
import org.apache.rocketmq.common.message.MessageExt;
import org.apache.rocketmq.common.message.MessageQueue;
+import org.apache.rocketmq.studio.common.domain.enums.DeliveryStatus;
import org.apache.rocketmq.studio.instance.message.MessageRecordVO;
+import org.apache.rocketmq.studio.instance.message.TraceNodeVO;
+import org.apache.rocketmq.studio.instance.message.TraceRecordVO;
import org.apache.rocketmq.studio.queryhistory.QueryHistoryService;
import org.apache.rocketmq.tools.admin.DefaultMQAdminExt;
import org.junit.jupiter.api.BeforeEach;
@@ -120,4 +124,68 @@ class RocketMQMessageProviderTest {
assertThat(record.getBody()).isEqualTo("wyg=");
assertThat(record.isBodyTruncated()).isFalse();
}
+
+ @Test
+ void getMessageTraceParsesPubAndSubAfterPerRocketMq533Layout() throws
Exception {
+ // Field order follows RocketMQ 5.3.3 TraceDataEncoder: Pub = type,
time, region, group,
+ // topic, msgId, tags, keys, storeHost, bodyLength, costTime, msgType,
offsetMsgId, isSuccess.
+ // SubAfter = type, requestId, msgId, costTime, isSuccess, keys,
contextCode, timeStamp,
+ // groupName.
+ String pub = "Pub" + '' + "1000" + '' + "cn" + '' + "prod-group"
+ + '' + "TopicA" + '' + "msg-123" + '' + "tag1" + '' +
"key1"
+ + '' + "broker:10911" + '' + "15" + '' + "50" + '' + "0"
+ + '' + "offset-1" + '' + "true";
+ String subAfter = "SubAfter" + '' + "req-1" + '' + "msg-123" + '' +
"20"
+ + '' + "true" + '' + "key1" + '' + "3" + '' + "3000"
+ + '' + "cons-group";
+ String otherMessage = "SubAfter" + '' + "req-2" + '' + "other-msg" +
'' + "5"
+ + '' + "false" + '' + "key-other" + '' + "0" + '' + "0"
+ + '' + "other-group";
+ MessageExt traceMessage = new MessageExt();
+ traceMessage.setBody(String.join("\n", pub, subAfter,
otherMessage).getBytes(StandardCharsets.UTF_8));
+ QueryResult queryResult = new QueryResult(0L, List.of(traceMessage));
+ when(adminExt.queryMessage(anyString(), anyString(), anyInt(),
anyLong(), anyLong()))
+ .thenReturn(queryResult);
+
+ TraceRecordVO record = provider.getMessageTrace("msg-123");
+
+ assertThat(record.getNodes()).hasSize(2);
+ TraceNodeVO produce = record.getNodes().get(0);
+ assertThat(produce.getTitle()).isEqualTo("produce");
+ assertThat(produce.getStatus()).isEqualTo("finish");
+ assertThat(produce.getCostTime()).isEqualTo(50);
+ assertThat(produce.getTimestamp()).isEqualTo(1000);
+
assertThat(produce.getDescription()).contains("prod-group").contains("broker:10911");
+ TraceNodeVO consume = record.getNodes().get(1);
+ assertThat(consume.getTitle()).isEqualTo("consume");
+ assertThat(consume.getStatus()).isEqualTo("finish");
+ assertThat(consume.getCostTime()).isEqualTo(20);
+ assertThat(consume.getTimestamp()).isEqualTo(3000);
+ assertThat(consume.getDescription()).contains("cons-group");
+ assertThat(record.getConsumerStatus()).hasSize(1);
+
assertThat(record.getConsumerStatus().get(0).getGroup()).isEqualTo("cons-group");
+ assertThat(record.getConsumerStatus().get(0).getDeliveryStatus())
+ .isEqualTo(DeliveryStatus.success);
+ }
+
+ @Test
+ void getMessageTraceParsesEndTransactionState() throws Exception {
+ String body = "EndTransaction" + '' + "2000" + '' + "cn" + '' +
"tx-group"
+ + '' + "TopicA" + '' + "msg-tx" + '' + "tag2" + '' + "key2"
+ + '' + "broker:10911" + '' + "10" + '' + "40" + '' + "0"
+ + '' + "tx-1" + '' + "COMMIT_MESSAGE";
+ MessageExt traceMessage = new MessageExt();
+ traceMessage.setBody(body.getBytes(StandardCharsets.UTF_8));
+ QueryResult queryResult = new QueryResult(0L, List.of(traceMessage));
+ when(adminExt.queryMessage(anyString(), anyString(), anyInt(),
anyLong(), anyLong()))
+ .thenReturn(queryResult);
+
+ TraceRecordVO record = provider.getMessageTrace("msg-tx");
+
+ assertThat(record.getNodes()).hasSize(1);
+ TraceNodeVO transaction = record.getNodes().get(0);
+ assertThat(transaction.getTitle()).isEqualTo("endTransaction");
+
assertThat(transaction.getDescription()).contains("tx-group").contains("COMMIT_MESSAGE");
+ assertThat(record.getConsumerStatus()).isEmpty();
+ }
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/rocketmq/RocketMQMetadataProviderTest.java
b/server/src/test/java/org/apache/rocketmq/studio/rocketmq/RocketMQMetadataProviderTest.java
new file mode 100644
index 00000000..7f06e7cb
--- /dev/null
+++
b/server/src/test/java/org/apache/rocketmq/studio/rocketmq/RocketMQMetadataProviderTest.java
@@ -0,0 +1,79 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.rocketmq;
+
+import org.apache.rocketmq.studio.common.domain.enums.ConsumeType;
+import org.apache.rocketmq.studio.instance.group.ConsumerGroupVO;
+import org.apache.rocketmq.studio.persistence.entity.RmqGroup;
+import org.apache.rocketmq.studio.persistence.mapper.RmqGroupMapper;
+import org.apache.rocketmq.studio.persistence.mapper.RmqTopicMapper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class RocketMQMetadataProviderTest {
+
+ @Mock
+ private RmqTopicMapper topicMapper;
+
+ @Mock
+ private RmqGroupMapper groupMapper;
+
+ @Test
+ void listConsumerGroupsReadsConsumeTypeColumn() {
+ RmqGroup entity = new RmqGroup();
+ entity.setName("group-broadcast");
+ entity.setClusterId("cluster-1");
+ entity.setConsumeType("BROADCASTING");
+ entity.setMessageModel("Push");
+ when(groupMapper.selectList(any())).thenReturn(List.of(entity));
+
+ RocketMQMetadataProvider provider =
+ new RocketMQMetadataProvider(null, topicMapper, groupMapper);
+
+ List<ConsumerGroupVO> groups =
provider.listConsumerGroups("cluster-1", null);
+
+ assertThat(groups).hasSize(1);
+
assertThat(groups.get(0).getConsumeType()).isEqualTo(ConsumeType.BROADCASTING);
+ }
+
+ @Test
+ void listConsumerGroupsFallsBackToClusteringWhenConsumeTypeIsBlank() {
+ RmqGroup entity = new RmqGroup();
+ entity.setName("group-legacy");
+ entity.setClusterId("cluster-1");
+ entity.setConsumeType(null);
+ entity.setMessageModel("Push");
+ when(groupMapper.selectList(any())).thenReturn(List.of(entity));
+
+ RocketMQMetadataProvider provider =
+ new RocketMQMetadataProvider(null, topicMapper, groupMapper);
+
+ List<ConsumerGroupVO> groups =
provider.listConsumerGroups("cluster-1", null);
+
+ assertThat(groups).hasSize(1);
+
assertThat(groups.get(0).getConsumeType()).isEqualTo(ConsumeType.CLUSTERING);
+ }
+}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsServiceTest.java
index 5a4de053..25d4ac22 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsServiceTest.java
@@ -373,6 +373,32 @@ class SettingsServiceTest {
assertThat(authorization.get()).isEqualTo("Bearer token-1");
}
+ @Test
+ void testConnectionShouldRejectLocalhostHostname() {
+ DataSourceTestDTO request = DataSourceTestDTO.builder()
+ .url("http://localhost:9090")
+ .type("Prometheus")
+ .build();
+
+ DataSourceTestResultVO result =
settingsService.testDataSource(request);
+
+ assertThat(result.isSuccess()).isFalse();
+ assertThat(result.getMessage()).contains("local or private address");
+ }
+
+ @Test
+ void testConnectionShouldRejectLinkLocalMetadataAddress() {
+ DataSourceTestDTO request = DataSourceTestDTO.builder()
+ .url("http://169.254.169.254/latest/meta-data/")
+ .type("Prometheus")
+ .build();
+
+ DataSourceTestResultVO result =
settingsService.testDataSource(request);
+
+ assertThat(result.isSuccess()).isFalse();
+ assertThat(result.getMessage()).contains("local or private address");
+ }
+
@Test
void testConnectionShouldRejectIncompleteBasicAuthentication() {
DataSourceTestResultVO result =
settingsService.testDataSource(DataSourceTestDTO.builder()