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 c406a0cc0 fix(server): normalize data entries and harden request
boundaries (#2243)
c406a0cc0 is described below
commit c406a0cc074461f1c067208a070056f45bbcba3a
Author: btlqql <[email protected]>
AuthorDate: Wed Aug 19 13:54:26 2026 +0800
fix(server): normalize data entries and harden request boundaries (#2243)
* fix(metrics): normalize data source configuration
* fix(cluster): tolerate malformed topology entries
* fix(topic): decode route permission bits
* fix: isolate malformed remote ACL rows
* fix: canonicalize alert channel entries
* fix(security): reject IPv6 unique-local addresses in SSRF guard
InetAddress does not classify fc00::/7 as link-local or site-local, so
unique-local addresses (notably the AWS EC2 IMDS IPv6 endpoint
fd00:ec2::254) slipped through UrlHostGuard. Add an explicit ULA check to
areAllowed so these are rejected even when loopback is permitted.
* fix(common): map unmapped routes and wrong methods to 404/405 instead of
500
Spring MVC throws NoResourceFoundException for unmatched routes and
HttpRequestMethodNotSupportedException for wrong methods; both fell through
to the generic catch-all handler and were reported as HTTP 500. Add explicit
handlers so they return 404/405 (and 415 for unsupported media types) in the
standard Result envelope.
* fix(dashboard): calculate today message delta
---
.../studio/cluster/broker/RealClusterProvider.java | 19 +++++-
.../studio/cluster/metrics/MetricsBackendType.java | 30 ++++++----
.../common/exception/GlobalExceptionHandler.java | 37 ++++++++++++
.../rocketmq/studio/common/util/UrlHostGuard.java | 16 +++++-
.../studio/instance/acl/ApacheAclReadService.java | 16 +++++-
.../studio/instance/acl/RemoteAclPolicyVO.java | 7 ++-
.../studio/ops/alert/AlertRuleRequestDTO.java | 15 ++++-
.../ops/alert/MybatisPlusAlertRepository.java | 15 +++--
.../provider/apache/RocketMQDashboardProvider.java | 28 ++++++---
.../provider/apache/RocketMQMetadataProvider.java | 13 ++---
.../rocketmq/studio/settings/DataSourceDTO.java | 9 ++-
.../cluster/broker/RealClusterProviderTest.java | 40 +++++++++++++
.../cluster/metrics/MetricsBackendTypeTest.java | 11 ++++
.../exception/GlobalExceptionHandlerTest.java | 23 ++++++++
.../studio/common/util/UrlHostGuardTest.java | 23 ++++++++
.../instance/acl/ApacheAclReadServiceTest.java | 67 +++++++++++++++++++++-
.../studio/ops/alert/AlertRuleRequestDTOTest.java | 51 ++++++++++++++++
.../ops/alert/MybatisPlusAlertRepositoryTest.java | 27 +++++++++
.../apache/RocketMQDashboardProviderTest.java | 48 +++++++++++++++-
.../apache/RocketMQMetadataProviderTest.java | 43 ++++++++++++++
.../studio/settings/DataSourceDTOTest.java | 67 ++++++++++++++++++++++
21 files changed, 562 insertions(+), 43 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/RealClusterProvider.java
b/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/RealClusterProvider.java
index c164a788e..be27a4d86 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/RealClusterProvider.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/RealClusterProvider.java
@@ -35,6 +35,7 @@ import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import java.util.Set;
/**
@@ -118,15 +119,24 @@ public class RealClusterProvider implements
ClusterProvider {
clusterInfo.getClusterAddrTable() == null ? Map.of() :
clusterInfo.getClusterAddrTable();
if (clusterAddrTable.isEmpty()) {
- return List.of(toClusterVO(namesrvAddr, "DefaultCluster",
brokerAddrTable.values()));
+ List<BrokerData> brokers = brokerAddrTable.values().stream()
+ .filter(Objects::nonNull)
+ .toList();
+ return brokers.isEmpty()
+ ? List.of()
+ : List.of(toClusterVO(namesrvAddr, "DefaultCluster",
brokers));
}
return clusterAddrTable.entrySet().stream()
+ .filter(entry -> entry.getKey() != null &&
!entry.getKey().isBlank())
+ .filter(entry -> entry.getValue() != null)
.sorted(Map.Entry.comparingByKey())
.map(entry -> toClusterVO(namesrvAddr, entry.getKey(),
entry.getValue().stream()
+ .filter(Objects::nonNull)
.map(brokerAddrTable::get)
- .filter(java.util.Objects::nonNull)
+ .filter(Objects::nonNull)
.toList()))
+ .filter(cluster -> !cluster.getBrokers().isEmpty())
.toList();
}
@@ -166,7 +176,10 @@ public class RealClusterProvider implements
ClusterProvider {
if (data.getBrokerAddrs() != null) {
addr = data.getBrokerAddrs().get(MixAll.MASTER_ID);
if (addr == null) {
- addr =
data.getBrokerAddrs().values().stream().findFirst().orElse(null);
+ addr = data.getBrokerAddrs().values().stream()
+ .filter(Objects::nonNull)
+ .findFirst()
+ .orElse(null);
}
}
return BrokerVO.builder()
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricsBackendType.java
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricsBackendType.java
index e30b8c021..f73a3c6a6 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricsBackendType.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricsBackendType.java
@@ -29,22 +29,29 @@ import java.util.Locale;
*/
public enum MetricsBackendType {
- PROMETHEUS("/api/v1/query_range", "/api/v1/query"),
- VICTORIA_METRICS("/select/0/prometheus/api/v1/query_range",
"/select/0/prometheus/api/v1/query"),
- THANOS("/api/v1/query_range", "/api/v1/query"),
- CORTEX("/api/v1/query_range", "/api/v1/query"),
- MIMIR("/prometheus/api/v1/query_range", "/prometheus/api/v1/query"),
- ARMS("/api/v1/query_range", "/api/v1/query"),
- CUSTOM("/api/v1/query_range", "/api/v1/query");
+ PROMETHEUS("Prometheus", "/api/v1/query_range", "/api/v1/query"),
+ VICTORIA_METRICS(
+ "VictoriaMetrics", "/select/0/prometheus/api/v1/query_range",
"/select/0/prometheus/api/v1/query"),
+ THANOS("Thanos", "/api/v1/query_range", "/api/v1/query"),
+ CORTEX("Cortex", "/api/v1/query_range", "/api/v1/query"),
+ MIMIR("Mimir", "/prometheus/api/v1/query_range",
"/prometheus/api/v1/query"),
+ ARMS("ARMS", "/api/v1/query_range", "/api/v1/query"),
+ CUSTOM("Custom", "/api/v1/query_range", "/api/v1/query");
+ private final String providerType;
private final String queryPath;
private final String instantQueryPath;
- MetricsBackendType(String queryPath, String instantQueryPath) {
+ MetricsBackendType(String providerType, String queryPath, String
instantQueryPath) {
+ this.providerType = providerType;
this.queryPath = queryPath;
this.instantQueryPath = instantQueryPath;
}
+ public String getProviderType() {
+ return providerType;
+ }
+
public String getQueryPath() {
return queryPath;
}
@@ -61,9 +68,12 @@ public enum MetricsBackendType {
if (providerType == null) {
return PROMETHEUS;
}
- return switch (providerType.trim().toUpperCase(Locale.ROOT)) {
+ String normalizedProviderType = providerType.trim()
+ .replaceAll("[\\s_-]+", "")
+ .toUpperCase(Locale.ROOT);
+ return switch (normalizedProviderType) {
case "PROMETHEUS" -> PROMETHEUS;
- case "VICTORIAMETRICS", "VICTORIA_METRICS", "VICTORIA" ->
VICTORIA_METRICS;
+ case "VICTORIAMETRICS", "VICTORIA" -> VICTORIA_METRICS;
case "THANOS" -> THANOS;
case "CORTEX" -> CORTEX;
case "MIMIR" -> MIMIR;
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/common/exception/GlobalExceptionHandler.java
b/server/src/main/java/org/apache/rocketmq/studio/common/exception/GlobalExceptionHandler.java
index 384c16a61..e0b3c33f0 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/common/exception/GlobalExceptionHandler.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/common/exception/GlobalExceptionHandler.java
@@ -22,12 +22,15 @@ import
org.apache.rocketmq.studio.ops.ai.LlmGatewayException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.web.HttpMediaTypeNotSupportedException;
+import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import
org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
+import org.springframework.web.servlet.resource.NoResourceFoundException;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@@ -92,4 +95,38 @@ public class GlobalExceptionHandler {
log.error("Unexpected exception", ex);
return Result.error(500, "Internal Server Error");
}
+
+ /**
+ * Spring MVC throws {@link NoResourceFoundException} for unmatched
routes. Without an
+ * explicit handler it would fall through to the generic catch-all and be
reported as
+ * HTTP 500 instead of the correct 404.
+ */
+ @ExceptionHandler(NoResourceFoundException.class)
+ @ResponseStatus(HttpStatus.NOT_FOUND)
+ public Result<?> handleNoResourceFoundException(NoResourceFoundException
ex) {
+ log.warn("Resource not found: {}", ex.getMessage());
+ return Result.error(HttpStatus.NOT_FOUND.value(), ex.getMessage());
+ }
+
+ /**
+ * Requests with an unsupported HTTP method must be reported as 405, not
500.
+ */
+ @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
+ @ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED)
+ public Result<?> handleHttpRequestMethodNotSupportedException(
+ HttpRequestMethodNotSupportedException ex) {
+ log.warn("Method not allowed: {}", ex.getMessage());
+ return Result.error(HttpStatus.METHOD_NOT_ALLOWED.value(),
ex.getMessage());
+ }
+
+ /**
+ * Requests with an unsupported content type must be reported as 415, not
500.
+ */
+ @ExceptionHandler(HttpMediaTypeNotSupportedException.class)
+ @ResponseStatus(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
+ public Result<?> handleHttpMediaTypeNotSupportedException(
+ HttpMediaTypeNotSupportedException ex) {
+ log.warn("Unsupported media type: {}", ex.getMessage());
+ return Result.error(HttpStatus.UNSUPPORTED_MEDIA_TYPE.value(),
ex.getMessage());
+ }
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/common/util/UrlHostGuard.java
b/server/src/main/java/org/apache/rocketmq/studio/common/util/UrlHostGuard.java
index 43c9ef3d3..289b55a9c 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/common/util/UrlHostGuard.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/common/util/UrlHostGuard.java
@@ -112,7 +112,7 @@ public final class UrlHostGuard {
}
for (InetAddress address : addresses) {
if (address == null || address.isAnyLocalAddress() ||
address.isLinkLocalAddress()
- || address.isMulticastAddress()) {
+ || address.isMulticastAddress() ||
isUniqueLocalAddress(address)) {
return false;
}
if (address.isLoopbackAddress() && !allowLoopback) {
@@ -121,4 +121,18 @@ public final class UrlHostGuard {
}
return true;
}
+
+ /**
+ * Whether the address is an IPv6 unique-local address (fc00::/7). Java's
+ * {@link InetAddress} does not classify these as link-local or
site-local, so they would
+ * otherwise pass the guard; notably {@code fd00:ec2::254} is the AWS EC2
IMDS IPv6
+ * metadata endpoint and must not be reachable.
+ */
+ private static boolean isUniqueLocalAddress(InetAddress address) {
+ if (!(address instanceof java.net.Inet6Address)) {
+ return false;
+ }
+ byte[] bytes = address.getAddress();
+ return bytes.length == 16 && (bytes[0] & 0xFE) == 0xFC;
+ }
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/ApacheAclReadService.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/ApacheAclReadService.java
index 9edc9adde..78735a875 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/ApacheAclReadService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/ApacheAclReadService.java
@@ -18,6 +18,7 @@ package org.apache.rocketmq.studio.instance.acl;
import lombok.RequiredArgsConstructor;
import org.apache.rocketmq.common.MixAll;
+import org.apache.rocketmq.remoting.protocol.body.AclInfo;
import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
import org.apache.rocketmq.remoting.protocol.route.BrokerData;
import org.apache.rocketmq.studio.cluster.broker.RuntimeAdminClientResolver;
@@ -26,6 +27,7 @@ import org.springframework.stereotype.Service;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
@Service
@RequiredArgsConstructor
@@ -46,9 +48,7 @@ public class ApacheAclReadService {
continue;
}
try {
- policies.put(address, admin.listAcl(address, subject,
resource).stream()
- .map(RemoteAclPolicyVO::from)
- .toList());
+ policies.put(address, mapPolicies(admin.listAcl(address,
subject, resource)));
} catch (Exception ex) {
failures.put(address, rootMessage(ex));
}
@@ -57,6 +57,16 @@ public class ApacheAclReadService {
});
}
+ private List<RemoteAclPolicyVO> mapPolicies(List<AclInfo> brokerPolicies) {
+ if (brokerPolicies == null) {
+ return List.of();
+ }
+ return brokerPolicies.stream()
+ .filter(Objects::nonNull)
+ .map(RemoteAclPolicyVO::from)
+ .toList();
+ }
+
private RemoteAclReadResult result(Map<String, List<RemoteAclPolicyVO>>
policies,
Map<String, String> failures) {
return RemoteAclReadResult.builder().source("APACHE_ACL2")
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/RemoteAclPolicyVO.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/RemoteAclPolicyVO.java
index a7f25c228..a19a07892 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/RemoteAclPolicyVO.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/RemoteAclPolicyVO.java
@@ -19,12 +19,14 @@ package org.apache.rocketmq.studio.instance.acl;
import org.apache.rocketmq.remoting.protocol.body.AclInfo;
import java.util.List;
+import java.util.Objects;
/** REST representation of an Apache ACL 2.0 policy. */
public record RemoteAclPolicyVO(String subject, List<PolicyGroupVO> policies) {
public static RemoteAclPolicyVO from(AclInfo policy) {
List<PolicyGroupVO> groups = policy.getPolicies() == null ? List.of()
: policy.getPolicies().stream()
+ .filter(Objects::nonNull)
.map(PolicyGroupVO::from)
.toList();
return new RemoteAclPolicyVO(policy.getSubject(), groups);
@@ -33,6 +35,7 @@ public record RemoteAclPolicyVO(String subject,
List<PolicyGroupVO> policies) {
public record PolicyGroupVO(String policyType, List<PolicyEntryVO>
entries) {
private static PolicyGroupVO from(AclInfo.PolicyInfo policy) {
List<PolicyEntryVO> policyEntries = policy.getEntries() == null ?
List.of() : policy.getEntries().stream()
+ .filter(Objects::nonNull)
.map(PolicyEntryVO::from)
.toList();
return new PolicyGroupVO(policy.getPolicyType(), policyEntries);
@@ -46,7 +49,9 @@ public record RemoteAclPolicyVO(String subject,
List<PolicyGroupVO> policies) {
}
private static List<String> listOrEmpty(List<String> values) {
- return values == null ? List.of() : List.copyOf(values);
+ return values == null ? List.of() : values.stream()
+ .filter(Objects::nonNull)
+ .toList();
}
}
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleRequestDTO.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleRequestDTO.java
index ec4d93a3f..52c73c5e6 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleRequestDTO.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleRequestDTO.java
@@ -34,7 +34,7 @@ public class AlertRuleRequestDTO {
private String thresholdUnit;
@Pattern(regexp = "(?:[0-9]+(?:ms|s|m|h|d|w|y))+", message = "duration is
invalid")
private String duration;
- private List<String> channels;
+ private List<@NotBlank(message = "channel must not be blank") String>
channels;
private boolean enabled;
private String description;
private String brokerName;
@@ -52,7 +52,7 @@ public class AlertRuleRequestDTO {
.threshold(threshold)
.thresholdUnit(thresholdUnit)
.duration(duration)
- .channels(channels)
+ .channels(normalizeChannels(channels))
.enabled(enabled)
.description(description)
.brokerName(brokerName)
@@ -60,4 +60,15 @@ public class AlertRuleRequestDTO {
.severity(severity)
.build();
}
+
+ private static List<String> normalizeChannels(List<String> values) {
+ if (values == null) {
+ return null;
+ }
+ return values.stream()
+ .filter(value -> value != null && !value.isBlank())
+ .map(String::trim)
+ .distinct()
+ .toList();
+ }
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertRepository.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertRepository.java
index 97c4822fc..c3c6d4538 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertRepository.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertRepository.java
@@ -127,7 +127,9 @@ public class MybatisPlusAlertRepository implements
AlertRepository {
entity.setThreshold(rule.getThreshold());
entity.setThresholdUnit(rule.getThresholdUnit());
entity.setDuration(rule.getDuration());
- entity.setChannels(rule.getChannels() == null ? null :
String.join(",", rule.getChannels()));
+ entity.setChannels(rule.getChannels() == null
+ ? null
+ : String.join(",", normalizeChannels(rule.getChannels())));
entity.setEnabled(rule.isEnabled());
entity.setLastTriggered(rule.getLastTriggered());
entity.setDescription(rule.getDescription());
@@ -176,9 +178,14 @@ public class MybatisPlusAlertRepository implements
AlertRepository {
if (!StringUtils.hasText(value)) {
return List.of();
}
- return Arrays.stream(value.split(","))
+ return normalizeChannels(Arrays.asList(value.split(",")));
+ }
+
+ private static List<String> normalizeChannels(List<String> channels) {
+ return channels.stream()
+ .filter(StringUtils::hasText)
.map(String::trim)
- .filter(part -> !part.isEmpty())
- .collect(Collectors.toList());
+ .distinct()
+ .toList();
}
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDashboardProvider.java
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDashboardProvider.java
index dc3699d5f..08fd392b7 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDashboardProvider.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDashboardProvider.java
@@ -235,13 +235,7 @@ public class RocketMQDashboardProvider implements
DashboardProvider {
tpsIn += parseTps(table.get("putTps"));
tpsOut += parseTps(table.get("getTransferredTps"));
- String msgPutToday =
table.get("msgPutTotalTodayMorning");
- if (msgPutToday != null) {
- try {
- messagesToday +=
Long.parseLong(msgPutToday.trim());
- } catch (NumberFormatException ignored) {
- }
- }
+ messagesToday += parseMessagesToday(table);
}
} catch (Exception e) {
log.warn("Failed to get runtime info from broker {}: {}",
brokerAddr, e.getMessage());
@@ -385,6 +379,26 @@ public class RocketMQDashboardProvider implements
DashboardProvider {
return 0;
}
+ private long parseMessagesToday(Map<String, String> runtimeStats) {
+ String morningValue = runtimeStats.get("msgPutTotalTodayMorning");
+ String currentValue = runtimeStats.get("msgPutTotalTodayNow");
+ if (morningValue == null || currentValue == null) {
+ return 0;
+ }
+ try {
+ long morning = Long.parseLong(morningValue.trim());
+ long current = Long.parseLong(currentValue.trim());
+ if (morning < 0 || current < 0) {
+ return 0;
+ }
+ return Math.max(0, current - morning);
+ } catch (NumberFormatException exception) {
+ log.debug("Failed to parse today's message counters: morning={},
current={}",
+ morningValue, currentValue);
+ return 0;
+ }
+ }
+
private boolean isSystemTopic(String topic) {
return SystemTopicFilter.isSystem(topic);
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMetadataProvider.java
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMetadataProvider.java
index 1b6986c2c..df46661ed 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMetadataProvider.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMetadataProvider.java
@@ -18,10 +18,10 @@ package org.apache.rocketmq.studio.provider.apache;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import org.apache.rocketmq.common.constant.PermName;
import org.apache.rocketmq.common.message.MessageQueue;
import org.apache.rocketmq.common.MixAll;
import org.apache.rocketmq.common.TopicConfig;
-import org.apache.rocketmq.common.constant.PermName;
import org.apache.rocketmq.remoting.protocol.admin.ConsumeStats;
import org.apache.rocketmq.remoting.protocol.admin.OffsetWrapper;
import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
@@ -568,14 +568,13 @@ public class RocketMQMetadataProvider implements
MetadataProvider {
}
private TopicPerm mapPerm(int perm) {
- // RocketMQ perm: 6=RW, 4=R, 2=W
- if (perm == 6) {
- return TopicPerm.RW;
- } else if (perm == 4) {
- return TopicPerm.RO;
- } else if (perm == 2) {
+ if (PermName.isReadable(perm)) {
+ return PermName.isWriteable(perm) ? TopicPerm.RW : TopicPerm.RO;
+ }
+ if (PermName.isWriteable(perm)) {
return TopicPerm.WO;
}
+ // TopicPerm has no inaccessible value; retain the historical fallback
for invalid masks.
return TopicPerm.RW;
}
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/settings/DataSourceDTO.java
b/server/src/main/java/org/apache/rocketmq/studio/settings/DataSourceDTO.java
index ff7fb9e83..25b6752f6 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/settings/DataSourceDTO.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/settings/DataSourceDTO.java
@@ -19,6 +19,7 @@ package org.apache.rocketmq.studio.settings;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
+import org.apache.rocketmq.studio.cluster.metrics.MetricsBackendType;
import java.util.List;
@@ -44,16 +45,18 @@ public class DataSourceDTO {
message = "Unsupported metrics data source authentication")
private String auth;
- private List<String> instanceIds;
+ private List<@NotBlank(message = "instanceIds must not contain blank
values") String> instanceIds;
public DataSourceVO toDataSourceVO() {
return DataSourceVO.builder()
.key(key)
.name(name)
- .type(type.trim())
+
.type(MetricsBackendType.fromProviderType(type).getProviderType())
.url(url)
.auth(auth == null ? null : auth.trim())
- .instanceIds(instanceIds)
+ .instanceIds(instanceIds == null
+ ? null
+ :
instanceIds.stream().map(String::trim).distinct().toList())
.build();
}
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/RealClusterProviderTest.java
b/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/RealClusterProviderTest.java
index 56ff3a9d6..82b5a7d75 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/RealClusterProviderTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/RealClusterProviderTest.java
@@ -29,6 +29,7 @@ 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.Set;
@@ -135,6 +136,45 @@ class RealClusterProviderTest {
assertThat(provider.describeClusters("10.0.0.1:9876")).isEmpty();
}
+ @Test
+ void describeClustersShouldSkipMalformedTopologyEntries() throws Exception
{
+ ClusterInfo info = new ClusterInfo();
+ HashMap<String, BrokerData> brokers = new HashMap<>();
+ brokers.put("broker-a", new BrokerData("ValidCluster", "broker-a",
+ new HashMap<>(java.util.Map.of(0L, "10.0.0.11:10911"))));
+ brokers.put("broken", null);
+ info.setBrokerAddrTable(brokers);
+
+ HashSet<String> brokerNames = new HashSet<>();
+ brokerNames.add("broker-a");
+ brokerNames.add("missing");
+ brokerNames.add(null);
+ HashMap<String, Set<String>> clusters = new HashMap<>();
+ clusters.put("ValidCluster", brokerNames);
+ clusters.put("NullSetCluster", null);
+ clusters.put(null, Set.of("broker-a"));
+ info.setClusterAddrTable(clusters);
+ stubClusterInfo("10.0.0.1:9876", info);
+
+ List<ClusterVO> result = provider.describeClusters("10.0.0.1:9876");
+
+
assertThat(result).extracting(ClusterVO::getName).containsExactly("ValidCluster");
+ assertThat(result.get(0).getBrokers()).extracting(BrokerVO::getName)
+ .containsExactly("broker-a");
+ }
+
+ @Test
+ void describeClustersShouldReturnEmptyWhenTopologyHasNoValidBrokers()
throws Exception {
+ ClusterInfo info = new ClusterInfo();
+ HashMap<String, BrokerData> brokers = new HashMap<>();
+ brokers.put("broken", null);
+ info.setBrokerAddrTable(brokers);
+ info.setClusterAddrTable(new HashMap<>());
+ stubClusterInfo("10.0.0.1:9876", info);
+
+ assertThat(provider.describeClusters("10.0.0.1:9876")).isEmpty();
+ }
+
@Test
void discoverClustersShouldUseConfiguredNamesrv() throws Exception {
properties.setNamesrvAddr("10.0.0.1:9876");
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MetricsBackendTypeTest.java
b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MetricsBackendTypeTest.java
index 2054bb661..d6dbcd7d6 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MetricsBackendTypeTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MetricsBackendTypeTest.java
@@ -31,6 +31,10 @@ class MetricsBackendTypeTest {
.isEqualTo(MetricsBackendType.VICTORIA_METRICS);
assertThat(MetricsBackendType.fromProviderType("VICTORIA_METRICS"))
.isEqualTo(MetricsBackendType.VICTORIA_METRICS);
+ assertThat(MetricsBackendType.fromProviderType("victoria metrics"))
+ .isEqualTo(MetricsBackendType.VICTORIA_METRICS);
+ assertThat(MetricsBackendType.fromProviderType("victoria-metrics"))
+ .isEqualTo(MetricsBackendType.VICTORIA_METRICS);
assertThat(MetricsBackendType.fromProviderType("THANOS")).isEqualTo(MetricsBackendType.THANOS);
assertThat(MetricsBackendType.fromProviderType("CORTEX")).isEqualTo(MetricsBackendType.CORTEX);
assertThat(MetricsBackendType.fromProviderType("MIMIR")).isEqualTo(MetricsBackendType.MIMIR);
@@ -71,6 +75,13 @@ class MetricsBackendTypeTest {
assertThat(MetricsBackendType.ARMS.getQueryPath()).isEqualTo("/api/v1/query_range");
}
+ @Test
+ void shouldExposeCanonicalProviderType() {
+
assertThat(MetricsBackendType.PROMETHEUS.getProviderType()).isEqualTo("Prometheus");
+
assertThat(MetricsBackendType.VICTORIA_METRICS.getProviderType()).isEqualTo("VictoriaMetrics");
+
assertThat(MetricsBackendType.ARMS.getProviderType()).isEqualTo("ARMS");
+ }
+
@Test
void shouldExposeDistinctInstantQueryPathsForBackends() {
assertThat(MetricsBackendType.PROMETHEUS.getInstantQueryPath()).isEqualTo("/api/v1/query");
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/common/exception/GlobalExceptionHandlerTest.java
b/server/src/test/java/org/apache/rocketmq/studio/common/exception/GlobalExceptionHandlerTest.java
index bfddc1142..af2b16961 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/common/exception/GlobalExceptionHandlerTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/common/exception/GlobalExceptionHandlerTest.java
@@ -65,6 +65,29 @@ class GlobalExceptionHandlerTest {
.andExpect(jsonPath("$.message").value("LLM provider request
timed out"));
}
+ @Test
+ void unmappedRouteReturns404WithEnvelopeInsteadOf500() {
+ // Standalone MockMvc does not synthesize NoResourceFoundException for
unmapped
+ // routes (only a full Spring MVC resource resolver does), so verify
the handler
+ // mapping directly: NoResourceFoundException must map to a 404 Result
envelope
+ // rather than falling through to the generic 500 catch-all.
+ GlobalExceptionHandler handler = new GlobalExceptionHandler();
+ org.springframework.web.servlet.resource.NoResourceFoundException ex =
+ new
org.springframework.web.servlet.resource.NoResourceFoundException(
+ org.springframework.http.HttpMethod.GET,
"missing-resource");
+ org.apache.rocketmq.studio.common.domain.Result<?> result =
+ handler.handleNoResourceFoundException(ex);
+
org.assertj.core.api.Assertions.assertThat(result.getCode()).isEqualTo(404);
+ }
+
+ @Test
+ void wrongHttpMethodReturns405WithEnvelopeInsteadOf500() throws Exception {
+
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
+ .post("/test/business/400"))
+ .andExpect(status().isMethodNotAllowed())
+ .andExpect(jsonPath("$.code").value(405));
+ }
+
@RestController
static class FailingController {
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/common/util/UrlHostGuardTest.java
b/server/src/test/java/org/apache/rocketmq/studio/common/util/UrlHostGuardTest.java
index f31ee2653..313982022 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/common/util/UrlHostGuardTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/common/util/UrlHostGuardTest.java
@@ -40,4 +40,27 @@ class UrlHostGuardTest {
assertThat(UrlHostGuard.areAllowed(new InetAddress[]{first, second},
false)).isTrue();
}
+
+ @Test
+ void areAllowedShouldRejectIpv6UniqueLocalAddresses() throws Exception {
+ // fc00::/7 unique-local addresses are not classified as
link/site-local by the JDK
+ // and would otherwise pass the guard; fd00:ec2::254 is the AWS EC2
IMDS IPv6 endpoint.
+ InetAddress ula = InetAddress.getByName("fd00:ec2::254");
+ InetAddress ulaFc = InetAddress.getByName("fc00::1");
+
+ assertThat(UrlHostGuard.areAllowed(new InetAddress[]{ula},
false)).isFalse();
+ assertThat(UrlHostGuard.areAllowed(new InetAddress[]{ulaFc},
false)).isFalse();
+ }
+
+ @Test
+ void areAllowedShouldStillAcceptGlobalIpv6Addresses() throws Exception {
+ InetAddress globalIpv6 = InetAddress.getByName("2606:4700:4700::1111");
+
+ assertThat(UrlHostGuard.areAllowed(new InetAddress[]{globalIpv6},
false)).isTrue();
+ }
+
+ @Test
+ void isAllowedHostShouldRejectIpv6UlaLiteralEvenWhenLoopbackIsAllowed() {
+ assertThat(UrlHostGuard.isAllowedHost("fd00:ec2::254",
true)).isFalse();
+ }
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/acl/ApacheAclReadServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/ApacheAclReadServiceTest.java
index 3bf178b53..51c79466b 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/acl/ApacheAclReadServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/ApacheAclReadServiceTest.java
@@ -16,6 +16,7 @@
*/
package org.apache.rocketmq.studio.instance.acl;
+import org.apache.rocketmq.remoting.protocol.body.AclInfo;
import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
import org.apache.rocketmq.remoting.protocol.route.BrokerData;
import org.apache.rocketmq.studio.cluster.broker.MqAdminExtFactory;
@@ -23,8 +24,9 @@ import
org.apache.rocketmq.studio.cluster.broker.RuntimeAdminClientResolver;
import org.apache.rocketmq.tools.admin.MQAdminExt;
import org.junit.jupiter.api.Test;
-import java.util.List;
+import java.util.Arrays;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@@ -58,4 +60,67 @@ class ApacheAclReadServiceTest {
assertThat(result.getFailuresByBroker()).containsEntry("broker-b:10911",
"unavailable");
assertThat(result.isPartial()).isTrue();
}
+
+ @Test
+ void treatsNullBrokerPolicyListAsEmptySuccess() throws Exception {
+ RuntimeAdminClientResolver resolver =
mock(RuntimeAdminClientResolver.class);
+ MQAdminExt admin = mock(MQAdminExt.class);
+
when(admin.examineBrokerClusterInfo()).thenReturn(clusterInfo("broker-a:10911"));
+ when(admin.listAcl("broker-a:10911", null, null)).thenReturn(null);
+ executeWith(resolver, admin);
+
+ RemoteAclReadResult result = new
ApacheAclReadService(resolver).listRules("instance-1", null, null);
+
+
assertThat(result.getPoliciesByBroker()).containsEntry("broker-a:10911",
List.of());
+ assertThat(result.getFailuresByBroker()).isEmpty();
+ assertThat(result.isPartial()).isFalse();
+ }
+
+ @Test
+ void retainsValidPoliciesWhenResponseContainsNullRows() throws Exception {
+ RuntimeAdminClientResolver resolver =
mock(RuntimeAdminClientResolver.class);
+ MQAdminExt admin = mock(MQAdminExt.class);
+ AclInfo.PolicyEntryInfo entry = AclInfo.PolicyEntryInfo.of(
+ "Topic:orders", Arrays.asList("PUB", null),
List.of("10.0.0.0/8"), "ALLOW");
+ AclInfo.PolicyInfo group = new AclInfo.PolicyInfo();
+ group.setPolicyType("CUSTOM");
+ group.setEntries(Arrays.asList(entry, null));
+ AclInfo policy = new AclInfo();
+ policy.setSubject("User:alice");
+ policy.setPolicies(Arrays.asList(group, null));
+
when(admin.examineBrokerClusterInfo()).thenReturn(clusterInfo("broker-a:10911"));
+ when(admin.listAcl("broker-a:10911", null,
null)).thenReturn(Arrays.asList(policy, null));
+ executeWith(resolver, admin);
+
+ RemoteAclReadResult result = new
ApacheAclReadService(resolver).listRules("instance-1", null, null);
+
+ assertThat(result.getFailuresByBroker()).isEmpty();
+ assertThat(result.getPoliciesByBroker().get("broker-a:10911"))
+ .singleElement()
+ .satisfies(remotePolicy -> {
+ assertThat(remotePolicy.subject()).isEqualTo("User:alice");
+
assertThat(remotePolicy.policies()).singleElement().satisfies(remoteGroup -> {
+
assertThat(remoteGroup.policyType()).isEqualTo("CUSTOM");
+
assertThat(remoteGroup.entries()).singleElement().satisfies(remoteEntry -> {
+
assertThat(remoteEntry.resource()).isEqualTo("Topic:orders");
+
assertThat(remoteEntry.actions()).containsExactly("PUB");
+ });
+ });
+ });
+ }
+
+ private ClusterInfo clusterInfo(String address) {
+ BrokerData broker = new BrokerData();
+ broker.setBrokerAddrs(new HashMap<>(Map.of(0L, address)));
+ ClusterInfo clusterInfo = new ClusterInfo();
+ clusterInfo.setBrokerAddrTable(Map.of("broker", broker));
+ return clusterInfo;
+ }
+
+ private void executeWith(RuntimeAdminClientResolver resolver, MQAdminExt
admin) throws Exception {
+ when(resolver.execute(eq("instance-1"), any())).thenAnswer(invocation
-> {
+ MqAdminExtFactory.AdminAction<?> action =
invocation.getArgument(1);
+ return action.apply(admin);
+ });
+ }
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleRequestDTOTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleRequestDTOTest.java
new file mode 100644
index 000000000..09d0dfbdd
--- /dev/null
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleRequestDTOTest.java
@@ -0,0 +1,51 @@
+/*
+ * 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.ops.alert;
+
+import jakarta.validation.Validation;
+import jakarta.validation.Validator;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class AlertRuleRequestDTOTest {
+
+ private final Validator validator =
Validation.buildDefaultValidatorFactory().getValidator();
+
+ @Test
+ void channelsShouldRejectNullAndBlankElements() {
+ AlertRuleRequestDTO request = new AlertRuleRequestDTO();
+ request.setName("High Lag");
+ request.setChannels(Arrays.asList("email", null, " "));
+
+ assertThat(validator.validate(request))
+ .extracting(violation -> violation.getMessage())
+ .containsOnly("channel must not be blank");
+ }
+
+ @Test
+ void toAlertRuleVOShouldTrimAndDeduplicateChannelsInInputOrder() {
+ AlertRuleRequestDTO request = new AlertRuleRequestDTO();
+ request.setName("High Lag");
+ request.setChannels(List.of(" email ", "sms", "email", " sms "));
+
+
assertThat(request.toAlertRuleVO().getChannels()).containsExactly("email",
"sms");
+ }
+}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertRepositoryTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertRepositoryTest.java
index ee945a647..0b81c7e4c 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertRepositoryTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertRepositoryTest.java
@@ -28,6 +28,7 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
+import java.util.Arrays;
import java.util.List;
import java.util.Locale;
@@ -70,6 +71,32 @@ class MybatisPlusAlertRepositoryTest {
org.apache.rocketmq.studio.common.domain.enums.AlertLevel.warning));
}
+ @Test
+ void saveRuleShouldCanonicalizeChannelsBeforePersistence() {
+ AlertRuleVO rule = AlertRuleVO.builder()
+ .id(1L)
+ .name("Lag")
+ .channels(Arrays.asList(" email ", null, "", "sms", "email", "
sms "))
+ .build();
+
+ repository.saveRule(rule);
+
+ verify(ruleMapper).insert(argThat((RmqAlertRule entity) ->
+ entity != null && "email,sms".equals(entity.getChannels())));
+ }
+
+ @Test
+ void findAllRulesShouldCanonicalizeLegacyStoredChannels() {
+ RmqAlertRule entity = new RmqAlertRule();
+ entity.setId(1L);
+ entity.setName("Lag");
+ entity.setChannels(" email ,,sms,email, sms ");
+ when(ruleMapper.selectList(any())).thenReturn(List.of(entity));
+
+ assertThat(repository.findAllRules()).singleElement()
+ .satisfies(rule ->
assertThat(rule.getChannels()).containsExactly("email", "sms"));
+ }
+
@Test
void acknowledgeAlertShouldReportUpdateOutcomeWithoutInserting() {
SystemAlertVO deleted =
SystemAlertVO.builder().id(1L).acknowledged(true).build();
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQDashboardProviderTest.java
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQDashboardProviderTest.java
index e12515fcd..6f5b4d516 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQDashboardProviderTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQDashboardProviderTest.java
@@ -436,6 +436,46 @@ class RocketMQDashboardProviderTest {
});
}
+ @Test
+ void dashboardShouldReportMessagesProducedSinceTodayMorning() throws
Exception {
+ DefaultMQAdminExt adminExt = mock(DefaultMQAdminExt.class);
+
when(adminExt.examineBrokerClusterInfo()).thenReturn(clusterInfoWithTwoMasters());
+ when(adminExt.fetchBrokerRuntimeStats("10.0.0.11:10911"))
+ .thenReturn(runtimeStats("2.0", "5.0", "1000", "1250"));
+ when(adminExt.fetchBrokerRuntimeStats("10.0.0.12:10911"))
+ .thenReturn(runtimeStats("2.0", "5.0", "400", "475"));
+
+ DashboardDataVO dashboard = newProvider(adminExt).getDashboardData();
+
+
assertThat(dashboard.getStats().getTotalMessagesToday()).isEqualTo(325L);
+ }
+
+ @Test
+ void
dashboardShouldNotReportNegativeCountWhenBrokerCounterMovesBackwards() throws
Exception {
+ DefaultMQAdminExt adminExt = mock(DefaultMQAdminExt.class);
+ when(adminExt.examineBrokerClusterInfo()).thenReturn(clusterInfo());
+ when(adminExt.fetchBrokerRuntimeStats("10.0.0.11:10911"))
+ .thenReturn(runtimeStats("2.0", "5.0", "1000", "50"));
+
+ DashboardDataVO dashboard = newProvider(adminExt).getDashboardData();
+
+ assertThat(dashboard.getStats().getTotalMessagesToday()).isZero();
+ }
+
+ @Test
+ void dashboardShouldIgnoreMalformedTodayMessageCounters() throws Exception
{
+ DefaultMQAdminExt adminExt = mock(DefaultMQAdminExt.class);
+
when(adminExt.examineBrokerClusterInfo()).thenReturn(clusterInfoWithTwoMasters());
+ when(adminExt.fetchBrokerRuntimeStats("10.0.0.11:10911"))
+ .thenReturn(runtimeStats("2.0", "5.0", "invalid", "1250"));
+ when(adminExt.fetchBrokerRuntimeStats("10.0.0.12:10911"))
+ .thenReturn(runtimeStats("2.0", "5.0", "400", "invalid"));
+
+ DashboardDataVO dashboard = newProvider(adminExt).getDashboardData();
+
+ assertThat(dashboard.getStats().getTotalMessagesToday()).isZero();
+ }
+
@Test
void dashboardShouldTolerateMissingTopicListAndClusterMembership() throws
Exception {
DefaultMQAdminExt adminExt = mock(DefaultMQAdminExt.class);
@@ -534,11 +574,17 @@ class RocketMQDashboardProviderTest {
}
private KVTable runtimeStats(String putTps, String getTransferredTps) {
+ return runtimeStats(putTps, getTransferredTps, "42", "84");
+ }
+
+ private KVTable runtimeStats(String putTps, String getTransferredTps,
+ String todayMorning, String todayNow) {
HashMap<String, String> table = new HashMap<>();
table.put("brokerVersionDesc", " V5_3_3 ");
table.put("putTps", "1.0 " + putTps + " 3.0");
table.put("getTransferredTps", "4.0 " + getTransferredTps + " 6.0");
- table.put("msgPutTotalTodayMorning", "42");
+ table.put("msgPutTotalTodayMorning", todayMorning);
+ table.put("msgPutTotalTodayNow", todayNow);
KVTable kvTable = new KVTable();
kvTable.setTable(table);
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQMetadataProviderTest.java
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQMetadataProviderTest.java
index 9baeeba4e..e6d5da09c 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQMetadataProviderTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQMetadataProviderTest.java
@@ -21,6 +21,10 @@ import org.apache.rocketmq.common.message.MessageQueue;
import org.apache.rocketmq.remoting.protocol.admin.ConsumeStats;
import org.apache.rocketmq.remoting.protocol.admin.OffsetWrapper;
import org.apache.rocketmq.remoting.protocol.body.GroupList;
+import org.apache.rocketmq.remoting.protocol.route.BrokerData;
+import org.apache.rocketmq.remoting.protocol.route.QueueData;
+import org.apache.rocketmq.remoting.protocol.route.TopicRouteData;
+import org.apache.rocketmq.studio.common.domain.enums.TopicPerm;
import org.apache.rocketmq.studio.common.domain.enums.ConsumeType;
import org.apache.rocketmq.studio.common.domain.enums.SubscriptionMode;
import org.apache.rocketmq.studio.common.exception.BusinessException;
@@ -38,9 +42,12 @@ 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.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
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.LinkedHashMap;
@@ -166,6 +173,42 @@ class RocketMQMetadataProviderTest {
verify(runtimeAdminClientResolver).execute(eq("instance-a"), any());
}
+ @ParameterizedTest
+ @CsvSource({
+ "0, RW",
+ "2, WO",
+ "3, WO",
+ "4, RO",
+ "5, RO",
+ "6, RW",
+ "7, RW"
+ })
+ void getTopicRoutesShouldInterpretPermissionBits(int permission, TopicPerm
expected) throws Exception {
+ DefaultMQAdminExt admin = mock(DefaultMQAdminExt.class);
+ QueueData queueData = new QueueData();
+ queueData.setBrokerName("broker-a");
+ queueData.setReadQueueNums(4);
+ queueData.setWriteQueueNums(2);
+ queueData.setPerm(permission);
+ BrokerData brokerData = new BrokerData();
+ brokerData.setBrokerName("broker-a");
+ brokerData.setBrokerAddrs(new HashMap<>(Map.of(0L, "10.0.0.1:10911")));
+ TopicRouteData routeData = new TopicRouteData();
+ routeData.setQueueDatas(List.of(queueData));
+ routeData.setBrokerDatas(List.of(brokerData));
+ when(admin.examineTopicRouteInfo("TopicA")).thenReturn(routeData);
+
+ List<BrokerRouteVO> routes =
newLiveProvider(admin).getTopicRoutes(null, "TopicA");
+
+ assertThat(routes).singleElement().satisfies(route -> {
+ assertThat(route.getBrokerName()).isEqualTo("broker-a");
+ assertThat(route.getBrokerAddr()).isEqualTo("10.0.0.1:10911");
+ assertThat(route.getReadQueues()).isEqualTo(4);
+ assertThat(route.getWriteQueues()).isEqualTo(2);
+ assertThat(route.getPerm()).isEqualTo(expected);
+ });
+ }
+
@Test
void getTopicConsumersShouldUseSelectedInstanceRuntimeClient() {
TopicConsumerPageVO consumers = TopicConsumerPageVO.builder()
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/settings/DataSourceDTOTest.java
b/server/src/test/java/org/apache/rocketmq/studio/settings/DataSourceDTOTest.java
new file mode 100644
index 000000000..e54bf8751
--- /dev/null
+++
b/server/src/test/java/org/apache/rocketmq/studio/settings/DataSourceDTOTest.java
@@ -0,0 +1,67 @@
+/*
+ * 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.settings;
+
+import jakarta.validation.Validation;
+import jakarta.validation.ValidatorFactory;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class DataSourceDTOTest {
+
+ @Test
+ void shouldCanonicalizeProviderTypeAndInstanceBindings() {
+ DataSourceDTO request = validDataSource();
+ request.setType(" victoria metrics ");
+ request.setInstanceIds(List.of(" instance-a ", "instance-b",
"instance-a"));
+
+ DataSourceVO dataSource = request.toDataSourceVO();
+
+ assertThat(dataSource.getType()).isEqualTo("VictoriaMetrics");
+ assertThat(dataSource.getInstanceIds()).containsExactly("instance-a",
"instance-b");
+ }
+
+ @Test
+ void shouldRejectBlankInstanceBindings() {
+ DataSourceDTO request = validDataSource();
+ request.setInstanceIds(List.of("instance-a", " "));
+
+ try (ValidatorFactory validatorFactory =
Validation.buildDefaultValidatorFactory()) {
+ assertThat(validatorFactory.getValidator().validate(request))
+ .anyMatch(violation -> "instanceIds must not contain blank
values"
+ .equals(violation.getMessage()));
+ }
+ }
+
+ @Test
+ void shouldKeepNullInstanceBindingsForGlobalDataSources() {
+ DataSourceDTO request = validDataSource();
+
+ assertThat(request.toDataSourceVO().getInstanceIds()).isNull();
+ }
+
+ private DataSourceDTO validDataSource() {
+ DataSourceDTO request = new DataSourceDTO();
+ request.setName("Production metrics");
+ request.setType("Prometheus");
+ request.setUrl("https://metrics.example.test");
+ return request;
+ }
+}