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 56b1bb638 fix: harden cluster, ACL, client, and polling behavior
(#2362)
56b1bb638 is described below
commit 56b1bb638c189a5aff76f5441e6e5b4c024382cd
Author: yyqdbngt <[email protected]>
AuthorDate: Fri Aug 21 17:10:16 2026 +0800
fix: harden cluster, ACL, client, and polling behavior (#2362)
* fix(cluster): isolate repository topology snapshots
* fix(provider): reject duplicate vendor registrations
* fix(web): serialize visible page polls
* feat(acl): support IPv6 whitelist ranges
* fix(mock): scope client connections by instance
* fix: mask credentials on Unicode boundaries
---
.../cluster/broker/ClusterRepositoryImpl.java | 55 ++++++++-
.../studio/common/util/CredentialUtils.java | 34 +++++-
.../studio/instance/acl/IpRangeMatcher.java | 78 +++++++-----
.../studio/provider/InstanceProviderRegistry.java | 16 ++-
.../cluster/broker/ClusterRepositoryImplTest.java | 25 ++++
.../studio/common/util/CredentialUtilsTest.java | 64 ++++++++++
.../studio/instance/acl/IpRangeMatcherTest.java | 30 ++++-
.../provider/InstanceProviderRegistryTest.java | 35 ++++++
web/src/hooks/useVisiblePolling.test.tsx | 131 +++++++++++++++++++++
web/src/hooks/useVisiblePolling.ts | 13 +-
web/src/mock/clients.ts | 9 ++
web/src/services/connectionsService.test.ts | 34 +++++-
web/src/services/connectionsService.ts | 11 +-
13 files changed, 480 insertions(+), 55 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/ClusterRepositoryImpl.java
b/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/ClusterRepositoryImpl.java
index 58d631106..a07d0c5ed 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/ClusterRepositoryImpl.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/ClusterRepositoryImpl.java
@@ -70,7 +70,7 @@ public class ClusterRepositoryImpl implements
ClusterRepository {
// observe a partially updated snapshot (new config, old updatedAt).
store.computeIfPresent(clusterId, (id, cluster) -> {
ClusterVO updated = defensiveCopy(cluster);
- updated.setConfig(config);
+ updated.setConfig(copyConfig(config));
updated.setGmtModified(LocalDateTime.now());
log.info("Config updated for cluster: {}", clusterId);
return updated;
@@ -89,11 +89,15 @@ public class ClusterRepositoryImpl implements
ClusterRepository {
.endpoint(cluster.getEndpoint())
.status(cluster.getStatus())
.version(cluster.getVersion())
- // new ArrayList forces a fresh list even when the source is
already immutable
- // (List.copyOf would return the source reference for
immutable inputs).
- .brokers(cluster.getBrokers() == null ? null : new
ArrayList<>(cluster.getBrokers()))
- .proxies(cluster.getProxies() == null ? null : new
ArrayList<>(cluster.getProxies()))
- .nameServers(cluster.getNameServers() == null ? null : new
ArrayList<>(cluster.getNameServers()))
+ .brokers(cluster.getBrokers() == null ? null : new
ArrayList<>(cluster.getBrokers().stream()
+ .map(this::copyBroker)
+ .toList()))
+ .proxies(cluster.getProxies() == null ? null : new
ArrayList<>(cluster.getProxies().stream()
+ .map(this::copyProxy)
+ .toList()))
+ .nameServers(cluster.getNameServers() == null ? null : new
ArrayList<>(cluster.getNameServers().stream()
+ .map(this::copyNameServer)
+ .toList()))
.config(copyConfig(cluster.getConfig()))
.topicCount(cluster.getTopicCount())
.groupCount(cluster.getGroupCount())
@@ -105,6 +109,45 @@ public class ClusterRepositoryImpl implements
ClusterRepository {
return copy;
}
+ private BrokerVO copyBroker(BrokerVO broker) {
+ if (broker == null) {
+ return null;
+ }
+ return BrokerVO.builder()
+ .name(broker.getName())
+ .addr(broker.getAddr())
+ .version(broker.getVersion())
+ .status(broker.getStatus())
+ .diskUsage(broker.getDiskUsage())
+ .tpsIn(broker.getTpsIn())
+ .tpsOut(broker.getTpsOut())
+ .runtimeStatsAvailable(broker.isRuntimeStatsAvailable())
+ .build();
+ }
+
+ private ProxyVO copyProxy(ProxyVO proxy) {
+ if (proxy == null) {
+ return null;
+ }
+ return ProxyVO.builder()
+ .addr(proxy.getAddr())
+ .status(proxy.getStatus())
+ .connections(proxy.getConnections())
+ .grpcPort(proxy.getGrpcPort())
+ .remotingPort(proxy.getRemotingPort())
+ .build();
+ }
+
+ private NameServerVO copyNameServer(NameServerVO nameServer) {
+ if (nameServer == null) {
+ return null;
+ }
+ return NameServerVO.builder()
+ .addr(nameServer.getAddr())
+ .status(nameServer.getStatus())
+ .build();
+ }
+
private ClusterConfigVO copyConfig(ClusterConfigVO config) {
if (config == null) {
return null;
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/common/util/CredentialUtils.java
b/server/src/main/java/org/apache/rocketmq/studio/common/util/CredentialUtils.java
index 718a5757b..b8c2ed372 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/common/util/CredentialUtils.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/common/util/CredentialUtils.java
@@ -61,18 +61,42 @@ public final class CredentialUtils {
}
/**
- * Keeps the first and last few characters visible, hides everything else;
short values are
- * fully masked.
+ * Keeps the first and last few Unicode code points visible and hides
everything else. Short
+ * values and malformed UTF-16 input are fully masked. Using code-point
boundaries prevents a
+ * supplementary character from being split into an isolated surrogate in
API responses.
*/
public static String mask(String value) {
if (value == null || value.isEmpty()) {
return value;
}
- if (value.length() < MIN_PARTIALLY_MASKED_CREDENTIAL_CHARS) {
+ if (hasUnpairedSurrogate(value)) {
return CREDENTIAL_MASK;
}
- return value.substring(0, VISIBLE_CREDENTIAL_CHARS)
+ int codePointCount = value.codePointCount(0, value.length());
+ if (codePointCount < MIN_PARTIALLY_MASKED_CREDENTIAL_CHARS) {
+ return CREDENTIAL_MASK;
+ }
+ int visiblePrefixEnd = value.offsetByCodePoints(0,
VISIBLE_CREDENTIAL_CHARS);
+ int visibleSuffixStart = value.offsetByCodePoints(
+ 0, codePointCount - VISIBLE_CREDENTIAL_CHARS);
+ return value.substring(0, visiblePrefixEnd)
+ CREDENTIAL_MASK
- + value.substring(value.length() - VISIBLE_CREDENTIAL_CHARS);
+ + value.substring(visibleSuffixStart);
+ }
+
+ private static boolean hasUnpairedSurrogate(String value) {
+ for (int index = 0; index < value.length(); index++) {
+ char current = value.charAt(index);
+ if (Character.isHighSurrogate(current)) {
+ if (index + 1 >= value.length()
+ || !Character.isLowSurrogate(value.charAt(index + 1)))
{
+ return true;
+ }
+ index++;
+ } else if (Character.isLowSurrogate(current)) {
+ return true;
+ }
+ }
+ return false;
}
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/IpRangeMatcher.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/IpRangeMatcher.java
index e9d9d74a8..1514eb630 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/IpRangeMatcher.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/IpRangeMatcher.java
@@ -17,6 +17,10 @@
package org.apache.rocketmq.studio.instance.acl;
+import java.net.Inet6Address;
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import java.util.Arrays;
import java.util.regex.Pattern;
/**
@@ -35,13 +39,11 @@ public final class IpRangeMatcher {
private IpRangeMatcher() {
}
- /**
- * Matches a strict dotted-quad IPv4 literal (each octet 0-255). Avoids
{@link InetAddress#getByName}
- * on purpose: that method performs DNS resolution, which would make ACL
whitelist validation
- * network-dependent and non-deterministic.
- */
+ /** Matches a strict dotted-quad IPv4 literal (each octet 0-255). */
private static final Pattern IPV4_LITERAL =
Pattern.compile("^(25[0-5]|2[0-4]\\d|1?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|1?\\d?\\d)){3}$");
+ private static final Pattern IPV6_LITERAL_CHARACTERS =
+ Pattern.compile("^[0-9a-fA-F:.]+$");
private static boolean isIpv4Literal(String value) {
return value != null && IPV4_LITERAL.matcher(value).matches();
@@ -52,9 +54,9 @@ public final class IpRangeMatcher {
*
* <ul>
* <li>{@code 0.0.0.0}, {@code 0.0.0.0/0} or {@code ::/0} match any
non-blank ip.</li>
- * <li>An entry without a {@code /} is matched by exact equality.</li>
- * <li>An entry of the form {@code x.x.x.x/n} is matched against the
IPv4 subnet.</li>
- * <li>Any unparseable input (or IPv6 CIDR other than {@code ::/0})
returns {@code false}.</li>
+ * <li>An entry without a {@code /} matches the equivalent IPv4 or IPv6
literal.</li>
+ * <li>IPv4 and IPv6 CIDR entries are matched using their respective
prefix widths.</li>
+ * <li>Any unparseable input or address-family mismatch returns {@code
false}.</li>
* </ul>
*
* @param ip the address being checked (IPv4 or IPv6)
@@ -65,6 +67,7 @@ public final class IpRangeMatcher {
if (ip == null || ip.isBlank() || cidrOrIp == null ||
cidrOrIp.isBlank()) {
return false;
}
+ String target = ip.trim();
String entry = cidrOrIp.trim();
if (WILDCARD_V4.equals(entry) || WILDCARD_V4_CIDR.equals(entry) ||
WILDCARD_V6_CIDR.equals(entry)) {
@@ -73,7 +76,9 @@ public final class IpRangeMatcher {
int slash = entry.indexOf('/');
if (slash < 0) {
- return ip.trim().equals(entry);
+ byte[] targetBytes = parseAddressLiteral(target);
+ byte[] entryBytes = parseAddressLiteral(entry);
+ return targetBytes != null && Arrays.equals(targetBytes,
entryBytes);
}
String baseIp = entry.substring(0, slash);
@@ -84,15 +89,12 @@ public final class IpRangeMatcher {
} catch (NumberFormatException e) {
return false;
}
- if (prefix < 0 || prefix > 32) {
- return false;
- }
- if (!isIpv4Literal(ip.trim()) || !isIpv4Literal(baseIp)) {
+ byte[] targetBytes = parseAddressLiteral(target);
+ byte[] baseBytes = parseAddressLiteral(baseIp);
+ if (targetBytes == null || baseBytes == null || targetBytes.length !=
baseBytes.length
+ || prefix < 0 || prefix > baseBytes.length * Byte.SIZE) {
return false;
}
-
- byte[] targetBytes = ipToBytes(ip.trim());
- byte[] baseBytes = ipToBytes(baseIp);
int bits = prefix;
for (int i = 0; i < targetBytes.length && bits > 0; i++) {
int mask = (bits >= 8) ? 0xFF : (0xFF << (8 - bits)) & 0xFF;
@@ -105,23 +107,37 @@ public final class IpRangeMatcher {
}
/**
- * Converts a validated dotted-quad IPv4 literal to its 4-byte
representation.
- * Callers must ensure the input passes {@link #isIpv4Literal(String)}
first.
+ * Parses an IPv4 or IPv6 literal without resolving hostnames. IPv6 input
is restricted to
+ * address-literal characters before using {@link
InetAddress#getByName(String)}, so this path
+ * cannot issue a DNS query. Scoped addresses are intentionally rejected
because interface
+ * names are host-specific and cannot form portable ACL entries.
*/
- private static byte[] ipToBytes(String ip) {
- String[] parts = ip.split("\\.");
- byte[] bytes = new byte[4];
- for (int i = 0; i < 4; i++) {
- bytes[i] = (byte) Integer.parseInt(parts[i]);
+ private static byte[] parseAddressLiteral(String value) {
+ if (isIpv4Literal(value)) {
+ String[] parts = value.split("\\.");
+ byte[] bytes = new byte[4];
+ for (int i = 0; i < bytes.length; i++) {
+ bytes[i] = (byte) Integer.parseInt(parts[i]);
+ }
+ return bytes;
+ }
+ if (value == null || !value.contains(":")
+ || !IPV6_LITERAL_CHARACTERS.matcher(value).matches()) {
+ return null;
+ }
+ try {
+ InetAddress address = InetAddress.getByName(value);
+ return address instanceof Inet6Address ? address.getAddress() :
null;
+ } catch (UnknownHostException exception) {
+ return null;
}
- return bytes;
}
/**
* Returns {@code true} when {@code cidrOrIp} is a well-formed whitelist
entry: a wildcard
- * ({@code 0.0.0.0}, {@code 0.0.0.0/0}, {@code ::/0}), a bare IPv4
address, or an IPv4 CIDR with a
- * prefix between 0 and 32. Used to validate ACL 2.0 {@code whiteSet}
entries before they are
- * applied.
+ * ({@code 0.0.0.0}, {@code 0.0.0.0/0}, {@code ::/0}), a bare IPv4/IPv6
address, or a CIDR with a
+ * prefix valid for that address family. Used to validate ACL 2.0 {@code
whiteSet} entries before
+ * they are applied.
*/
public static boolean isValidRange(String cidrOrIp) {
if (cidrOrIp == null || cidrOrIp.isBlank()) {
@@ -133,7 +149,7 @@ public final class IpRangeMatcher {
}
int slash = entry.indexOf('/');
if (slash < 0) {
- return isIpv4Literal(entry);
+ return parseAddressLiteral(entry) != null;
}
String baseIp = entry.substring(0, slash);
String prefixStr = entry.substring(slash + 1);
@@ -143,9 +159,7 @@ public final class IpRangeMatcher {
} catch (NumberFormatException e) {
return false;
}
- if (prefix < 0 || prefix > 32) {
- return false;
- }
- return isIpv4Literal(baseIp);
+ byte[] baseBytes = parseAddressLiteral(baseIp);
+ return baseBytes != null && prefix >= 0 && prefix <= baseBytes.length
* Byte.SIZE;
}
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/provider/InstanceProviderRegistry.java
b/server/src/main/java/org/apache/rocketmq/studio/provider/InstanceProviderRegistry.java
index ebfce9eee..ceb77a1bb 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/provider/InstanceProviderRegistry.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/provider/InstanceProviderRegistry.java
@@ -37,11 +37,23 @@ public class InstanceProviderRegistry {
public InstanceProviderRegistry(List<InstanceProvider> providerList,
List<CloudCatalogProvider> catalogList,
InstanceRepository instanceRepository) {
- providerList.forEach(provider -> providers.put(provider.vendor(),
provider));
- catalogList.forEach(catalog -> catalogs.put(catalog.vendor(),
catalog));
+ providerList.forEach(provider -> registerProvider(provider.vendor(),
provider));
+ catalogList.forEach(catalog -> registerCatalog(catalog.vendor(),
catalog));
this.instanceRepository = instanceRepository;
}
+ private void registerProvider(InstanceVendor vendor, InstanceProvider
provider) {
+ if (providers.putIfAbsent(vendor, provider) != null) {
+ throw new IllegalStateException("Duplicate instance provider
registered for vendor " + vendor);
+ }
+ }
+
+ private void registerCatalog(InstanceVendor vendor, CloudCatalogProvider
catalog) {
+ if (catalogs.putIfAbsent(vendor, catalog) != null) {
+ throw new IllegalStateException("Duplicate cloud catalog provider
registered for vendor " + vendor);
+ }
+ }
+
public InstanceProvider forVendor(InstanceVendor vendor) {
InstanceProvider provider = providers.get(vendor);
if (provider == null) {
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/ClusterRepositoryImplTest.java
b/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/ClusterRepositoryImplTest.java
index a0cb555bc..721b9a1c5 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/ClusterRepositoryImplTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/ClusterRepositoryImplTest.java
@@ -16,6 +16,7 @@
*/
package org.apache.rocketmq.studio.cluster.broker;
+import org.apache.rocketmq.studio.cluster.config.ClusterConfigVO;
import org.junit.jupiter.api.Test;
import java.util.List;
@@ -48,13 +49,37 @@ class ClusterRepositoryImplTest {
ClusterVO first = repository.findById("cluster-001").orElseThrow();
first.setName("mutated");
first.getConfig().setFileReservedTime(1);
+ first.getBrokers().get(0).setName("mutated-broker");
+ first.getProxies().get(0).setAddr("mutated-proxy");
+ first.getNameServers().get(0).setAddr("mutated-nameserver");
+ first.getTpsHistory().set(0, 999);
ClusterVO second = repository.findById("cluster-001").orElseThrow();
// Mutating the returned copy must not affect the cached cluster.
assertThat(second.getName()).isEqualTo("rmq-cluster-prod");
assertThat(first.getBrokers()).isNotSameAs(second.getBrokers());
+
assertThat(first.getBrokers().get(0)).isNotSameAs(second.getBrokers().get(0));
+ assertThat(second.getBrokers().get(0).getName()).isEqualTo("broker-a");
+
assertThat(second.getProxies().get(0).getAddr()).isEqualTo("10.0.0.10:8081");
+
assertThat(second.getNameServers().get(0).getAddr()).isEqualTo("10.0.0.20:9876");
+ assertThat(first.getTpsHistory()).isNotSameAs(second.getTpsHistory());
+ assertThat(second.getTpsHistory().get(0)).isEqualTo(1200);
assertThat(second.getConfig()).isNotSameAs(first.getConfig());
assertThat(second.getConfig().getFileReservedTime()).isEqualTo(72);
}
+
+ @Test
+ void updateConfigShouldNotRetainCallerOwnedObject() {
+ ClusterRepositoryImpl repository = new ClusterRepositoryImpl(true);
+ ClusterConfigVO config = ClusterConfigVO.builder()
+ .fileReservedTime(24)
+ .build();
+
+ repository.updateConfig("cluster-001", config);
+ config.setFileReservedTime(1);
+
+
assertThat(repository.findById("cluster-001").orElseThrow().getConfig().getFileReservedTime())
+ .isEqualTo(24);
+ }
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/common/util/CredentialUtilsTest.java
b/server/src/test/java/org/apache/rocketmq/studio/common/util/CredentialUtilsTest.java
index 698b6c56a..8fc57c9f8 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/common/util/CredentialUtilsTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/common/util/CredentialUtilsTest.java
@@ -30,4 +30,68 @@ class CredentialUtilsTest {
assertThat(CredentialUtils.decodeBase64(legacyValue)).isEqualTo(legacyValue);
}
+
+ @Test
+ void maskShouldPreserveAsciiBehavior() {
+ assertThat(CredentialUtils.mask("abcdefghijklmnopq"))
+ .isEqualTo("abcd****nopq");
+ assertThat(CredentialUtils.mask("abcdefghijklmnop"))
+ .isEqualTo("****");
+ }
+
+ @Test
+ void maskShouldUseSupplementaryCodePointBoundaries() {
+ String value = "abc🚀" + "123456789" + "🛰xyz";
+
+ String masked = CredentialUtils.mask(value);
+
+ assertThat(masked).isEqualTo("abc🚀****🛰xyz");
+ assertThat(hasIsolatedSurrogate(masked)).isFalse();
+ }
+
+ @Test
+ void maskShouldKeepFourCodePointsAtEachEnd() {
+ String value = "🚀abc" + "123456789" + "xyz🛰";
+
+ assertThat(CredentialUtils.mask(value)).isEqualTo("🚀abc****xyz🛰");
+ }
+
+ @Test
+ void maskShouldMeasureShortValuesInCodePoints() {
+ assertThat(CredentialUtils.mask("🚀".repeat(16))).isEqualTo("****");
+ assertThat(CredentialUtils.mask("🚀".repeat(17)))
+ .isEqualTo("🚀".repeat(4) + "****" + "🚀".repeat(4));
+ }
+
+ @Test
+ void maskShouldPreserveNullAndEmptyValues() {
+ assertThat(CredentialUtils.mask(null)).isNull();
+ assertThat(CredentialUtils.mask("")).isEmpty();
+ }
+
+ @Test
+ void maskShouldFullyHideMalformedUtf16WithoutLeakingSurrogates() {
+ String malformed = "abcd" + '\uD83D' + "1234567890123456";
+
+ String masked = CredentialUtils.mask(malformed);
+
+ assertThat(masked).isEqualTo("****");
+ assertThat(hasIsolatedSurrogate(masked)).isFalse();
+ }
+
+ private static boolean hasIsolatedSurrogate(String value) {
+ for (int index = 0; index < value.length(); index++) {
+ char current = value.charAt(index);
+ if (Character.isHighSurrogate(current)) {
+ if (index + 1 >= value.length()
+ || !Character.isLowSurrogate(value.charAt(index + 1)))
{
+ return true;
+ }
+ index++;
+ } else if (Character.isLowSurrogate(current)) {
+ return true;
+ }
+ }
+ return false;
+ }
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/acl/IpRangeMatcherTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/IpRangeMatcherTest.java
index 7c34f9a0b..32123b2e7 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/acl/IpRangeMatcherTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/IpRangeMatcherTest.java
@@ -76,8 +76,19 @@ class IpRangeMatcherTest {
}
@Test
- void ipv6CidrOtherThanWildcardIsNotMatched() {
- assertThat(IpRangeMatcher.isInRange("2001:db8::1",
"2001:db8::/32")).isFalse();
+ void ipv6CidrMatchesEquivalentAddressesInSubnet() {
+ assertThat(IpRangeMatcher.isInRange("2001:db8::1",
"2001:db8::/32")).isTrue();
+ assertThat(IpRangeMatcher.isInRange("2001:db8:10::abcd",
"2001:db8:10::/64")).isTrue();
+ assertThat(IpRangeMatcher.isInRange("2001:db9::1",
"2001:db8::/32")).isFalse();
+ assertThat(IpRangeMatcher.isInRange("2001:db8:11::1",
"2001:db8:10::/64")).isFalse();
+ }
+
+ @Test
+ void ipv6ExactAndSingleHostCidrUseCanonicalAddressBytes() {
+ assertThat(IpRangeMatcher.isInRange(
+ "2001:0db8:0000:0000:0000:0000:0000:0001",
"2001:db8::1")).isTrue();
+ assertThat(IpRangeMatcher.isInRange("2001:db8::1",
"2001:db8::1/128")).isTrue();
+ assertThat(IpRangeMatcher.isInRange("2001:db8::2",
"2001:db8::1/128")).isFalse();
}
@Test
@@ -86,6 +97,15 @@ class IpRangeMatcherTest {
assertThat(IpRangeMatcher.isInRange("192.168.1.10",
"2001:db8::/64")).isFalse();
}
+ @Test
+ void malformedIpv6AndInvalidPrefixesReturnFalse() {
+ assertThat(IpRangeMatcher.isInRange("2001:db8::1",
"2001:db8::/129")).isFalse();
+ assertThat(IpRangeMatcher.isInRange("2001:db8::1",
"2001:db8::/-1")).isFalse();
+ assertThat(IpRangeMatcher.isInRange("2001:db8::1",
"2001:db8::/abc")).isFalse();
+ assertThat(IpRangeMatcher.isInRange("2001:db8::1",
"2001:db8::1::2/64")).isFalse();
+ assertThat(IpRangeMatcher.isInRange("fe80::1%eth0",
"fe80::/10")).isFalse();
+ }
+
@Test
void leadingAndTrailingWhitespaceIsTrimmed() {
assertThat(IpRangeMatcher.isInRange(" 192.168.1.42 ", " 192.168.1.0/24
")).isTrue();
@@ -104,6 +124,9 @@ class IpRangeMatcherTest {
assertThat(IpRangeMatcher.isValidRange("192.168.1.10")).isTrue();
assertThat(IpRangeMatcher.isValidRange("192.168.1.0/24")).isTrue();
assertThat(IpRangeMatcher.isValidRange("10.0.0.1/32")).isTrue();
+ assertThat(IpRangeMatcher.isValidRange("2001:db8::1")).isTrue();
+ assertThat(IpRangeMatcher.isValidRange("2001:db8::/32")).isTrue();
+ assertThat(IpRangeMatcher.isValidRange("2001:db8::1/128")).isTrue();
}
@Test
@@ -115,5 +138,8 @@ class IpRangeMatcherTest {
assertThat(IpRangeMatcher.isValidRange("192.168.1.0/")).isFalse();
assertThat(IpRangeMatcher.isValidRange("10.0.0.1/abc")).isFalse();
assertThat(IpRangeMatcher.isValidRange("256.1.1.1")).isFalse();
+ assertThat(IpRangeMatcher.isValidRange("2001:db8::/129")).isFalse();
+ assertThat(IpRangeMatcher.isValidRange("2001:db8::1::2")).isFalse();
+ assertThat(IpRangeMatcher.isValidRange("fe80::1%eth0")).isFalse();
}
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/provider/InstanceProviderRegistryTest.java
b/server/src/test/java/org/apache/rocketmq/studio/provider/InstanceProviderRegistryTest.java
index 84433c9bd..20f4b377e 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/provider/InstanceProviderRegistryTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/provider/InstanceProviderRegistryTest.java
@@ -101,9 +101,44 @@ class InstanceProviderRegistryTest {
.satisfies(ex -> assertThat(((BusinessException)
ex).getCode()).isEqualTo(501));
}
+ @Test
+ void constructorShouldRejectDuplicateProvidersForVendorTest() {
+ InstanceProvider duplicate = stubProvider(InstanceVendor.APACHE);
+
+ assertThatThrownBy(() -> new InstanceProviderRegistry(
+ List.of(apacheProvider, duplicate), List.of(),
instanceRepository))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("Duplicate instance provider registered for vendor
APACHE");
+ }
+
+ @Test
+ void constructorShouldRejectDuplicateCatalogsForVendorTest() {
+ CloudCatalogProvider first = stubCatalog(InstanceVendor.ALIYUN);
+ CloudCatalogProvider duplicate = stubCatalog(InstanceVendor.ALIYUN);
+
+ assertThatThrownBy(() -> new InstanceProviderRegistry(
+ List.of(apacheProvider), List.of(first, duplicate),
instanceRepository))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("Duplicate cloud catalog provider registered for
vendor ALIYUN");
+ }
+
+ @Test
+ void catalogForShouldReturnRegisteredCatalogTest() {
+ CloudCatalogProvider catalog = stubCatalog(InstanceVendor.ALIYUN);
+ registry = new InstanceProviderRegistry(List.of(apacheProvider),
List.of(catalog), instanceRepository);
+
+
assertThat(registry.catalogFor(InstanceVendor.ALIYUN)).isSameAs(catalog);
+ }
+
private InstanceProvider stubProvider(InstanceVendor vendor) {
InstanceProvider provider = mock(InstanceProvider.class);
when(provider.vendor()).thenReturn(vendor);
return provider;
}
+
+ private CloudCatalogProvider stubCatalog(InstanceVendor vendor) {
+ CloudCatalogProvider catalog = mock(CloudCatalogProvider.class);
+ when(catalog.vendor()).thenReturn(vendor);
+ return catalog;
+ }
}
diff --git a/web/src/hooks/useVisiblePolling.test.tsx
b/web/src/hooks/useVisiblePolling.test.tsx
new file mode 100644
index 000000000..6682fe69b
--- /dev/null
+++ b/web/src/hooks/useVisiblePolling.test.tsx
@@ -0,0 +1,131 @@
+/*
+ * 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.
+ */
+
+import { act, renderHook } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { useVisiblePolling } from './useVisiblePolling';
+
+function deferred() {
+ let resolve!: () => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise<void>((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ return { promise, resolve, reject };
+}
+
+describe('useVisiblePolling', () => {
+ let visibilityState = 'visible';
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.spyOn(document, 'visibilityState', 'get').mockImplementation(
+ () => visibilityState as DocumentVisibilityState,
+ );
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ });
+
+ it('does not overlap interval and visibility-triggered polls', async () => {
+ const first = deferred();
+ const poll =
vi.fn().mockReturnValueOnce(first.promise).mockResolvedValue(undefined);
+
+ renderHook(() => useVisiblePolling(true, 1_000, poll));
+
+ await act(async () => {
+ vi.advanceTimersByTime(1_000);
+ await Promise.resolve();
+ });
+ expect(poll).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ vi.advanceTimersByTime(2_000);
+ document.dispatchEvent(new Event('visibilitychange'));
+ await Promise.resolve();
+ });
+ expect(poll).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ first.resolve();
+ await first.promise;
+ await Promise.resolve();
+ });
+
+ await act(async () => {
+ vi.advanceTimersByTime(1_000);
+ await Promise.resolve();
+ });
+ expect(poll).toHaveBeenCalledTimes(2);
+ });
+
+ it('releases the in-flight guard after a rejected poll', async () => {
+ const first = deferred();
+ const poll =
vi.fn().mockReturnValueOnce(first.promise).mockResolvedValue(undefined);
+
+ renderHook(() => useVisiblePolling(true, 1_000, poll));
+ await act(async () => {
+ vi.advanceTimersByTime(1_000);
+ await Promise.resolve();
+ first.reject(new Error('temporary failure'));
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ await act(async () => {
+ vi.advanceTimersByTime(1_000);
+ await Promise.resolve();
+ });
+ expect(poll).toHaveBeenCalledTimes(2);
+ });
+
+ it('stops scheduling polls after unmount', async () => {
+ const poll = vi.fn().mockResolvedValue(undefined);
+ const { unmount } = renderHook(() => useVisiblePolling(true, 1_000, poll));
+
+ unmount();
+ await act(async () => {
+ vi.advanceTimersByTime(2_000);
+ document.dispatchEvent(new Event('visibilitychange'));
+ await Promise.resolve();
+ });
+
+ expect(poll).not.toHaveBeenCalled();
+ });
+
+ it('waits for a hidden page to become visible', async () => {
+ visibilityState = 'hidden';
+ const poll = vi.fn().mockResolvedValue(undefined);
+
+ renderHook(() => useVisiblePolling(true, 1_000, poll));
+ await act(async () => {
+ vi.advanceTimersByTime(2_000);
+ await Promise.resolve();
+ });
+ expect(poll).not.toHaveBeenCalled();
+
+ visibilityState = 'visible';
+ await act(async () => {
+ document.dispatchEvent(new Event('visibilitychange'));
+ await Promise.resolve();
+ });
+ expect(poll).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/web/src/hooks/useVisiblePolling.ts
b/web/src/hooks/useVisiblePolling.ts
index 0d586e052..bcdc2cd2f 100644
--- a/web/src/hooks/useVisiblePolling.ts
+++ b/web/src/hooks/useVisiblePolling.ts
@@ -25,10 +25,17 @@ export function useVisiblePolling(
useEffect(() => {
if (!enabled) return;
+ let pollInFlight = false;
const pollWhenVisible = () => {
- if (document.visibilityState === 'visible') {
- void poll();
- }
+ if (document.visibilityState !== 'visible' || pollInFlight) return;
+
+ pollInFlight = true;
+ void Promise.resolve()
+ .then(poll)
+ .catch(() => undefined)
+ .finally(() => {
+ pollInFlight = false;
+ });
};
const intervalId = window.setInterval(pollWhenVisible, intervalMs);
document.addEventListener('visibilitychange', pollWhenVisible);
diff --git a/web/src/mock/clients.ts b/web/src/mock/clients.ts
index 6ece01ac8..029f79d20 100644
--- a/web/src/mock/clients.ts
+++ b/web/src/mock/clients.ts
@@ -31,6 +31,15 @@ export interface ClientConnection {
/* ─── Mock Data ─── */
+/**
+ * Associates mock client inventories with the NameServer endpoints that own
them. The real
+ * endpoint is NameServer-scoped, so mock mode must not expose every cluster
for every selection.
+ */
+export const mockClientClusterByNamesrvAddr: Readonly<Record<string, string>>
= {
+ '10.101.2.1:9876': 'ns-prod',
+ '10.102.5.1:9876': 'ns-pre',
+};
+
export const mockClients: ClientConnection[] = [
{
clientId: '[email protected]:49152',
diff --git a/web/src/services/connectionsService.test.ts
b/web/src/services/connectionsService.test.ts
index 393c767e3..625eb02d7 100644
--- a/web/src/services/connectionsService.test.ts
+++ b/web/src/services/connectionsService.test.ts
@@ -25,9 +25,31 @@ vi.mock('../config', () => ({
import { listConnections } from './connectionsService';
describe('connectionsService mock connections', () => {
- it('returns defensive copies after applying filters', async () => {
+ it('isolates client inventories by the required NameServer address', async
() => {
+ const production = await listConnections({ namesrvAddr: '10.101.2.1:9876'
});
+ const preproduction = await listConnections({ namesrvAddr:
'10.102.5.1:9876' });
+
+ expect(production).not.toHaveLength(0);
+ expect(preproduction).not.toHaveLength(0);
+ expect(production.every((connection) => connection.clusterName ===
'ns-prod')).toBe(true);
+ expect(preproduction.every((connection) => connection.clusterName ===
'ns-pre')).toBe(true);
+ expect(new Set(production.map((connection) =>
connection.clientId))).not.toEqual(
+ new Set(preproduction.map((connection) => connection.clientId)),
+ );
+ });
+
+ it('returns an empty inventory for an unknown NameServer address', async ()
=> {
+ await expect(listConnections({ namesrvAddr: '10.103.9.1:9876'
})).resolves.toEqual([]);
+ });
+
+ it('requires the same NameServer parameter as the real endpoint', async ()
=> {
+ await expect(listConnections()).rejects.toThrow('namesrvAddr is required');
+ await expect(listConnections({ namesrvAddr: ' '
})).rejects.toThrow('namesrvAddr is required');
+ });
+
+ it('returns defensive copies after applying NameServer, cluster, and type
filters', async () => {
const connections = await listConnections({
- namesrvAddr: 'namesrv-1:9876',
+ namesrvAddr: '10.101.2.1:9876',
clusterId: 'ns-prod',
type: 'Consumer',
});
@@ -38,7 +60,7 @@ describe('connectionsService mock connections', () => {
connections[0].address = '127.0.0.1:8081';
const fresh = await listConnections({
- namesrvAddr: 'namesrv-1:9876',
+ namesrvAddr: '10.101.2.1:9876',
clusterId: 'ns-prod',
type: 'Consumer',
});
@@ -49,4 +71,10 @@ describe('connectionsService mock connections', () => {
expect(fresh.every((connection) => connection.clusterName ===
'ns-prod')).toBe(true);
expect(fresh.every((connection) => connection.type ===
'Consumer')).toBe(true);
});
+
+ it('does not leak another NameServer when a conflicting cluster filter is
supplied', async () => {
+ await expect(
+ listConnections({ namesrvAddr: '10.101.2.1:9876', clusterId: 'ns-pre' }),
+ ).resolves.toEqual([]);
+ });
});
diff --git a/web/src/services/connectionsService.ts
b/web/src/services/connectionsService.ts
index d4adc7284..44eba5a96 100644
--- a/web/src/services/connectionsService.ts
+++ b/web/src/services/connectionsService.ts
@@ -1,7 +1,7 @@
import { isMockMode } from './dataMode';
import * as connApi from '../api/connections';
import type { ClientConnection, ClientConnectionQuery } from
'../api/connections';
-import { mockClients } from '../mock/clients';
+import { mockClientClusterByNamesrvAddr, mockClients } from '../mock/clients';
function copyConnection(connection: ClientConnection): ClientConnection {
return { ...connection };
@@ -9,7 +9,14 @@ function copyConnection(connection: ClientConnection):
ClientConnection {
export async function listConnections(params?: ClientConnectionQuery):
Promise<ClientConnection[]> {
if (isMockMode()) {
- let result = [...mockClients];
+ const namesrvAddr = params?.namesrvAddr?.trim();
+ if (!namesrvAddr) {
+ throw new Error('namesrvAddr is required');
+ }
+ const instanceCluster = mockClientClusterByNamesrvAddr[namesrvAddr];
+ if (!instanceCluster) return [];
+
+ let result = mockClients.filter((connection) => connection.clusterName ===
instanceCluster);
if (params?.clusterId)
result = result.filter((connection) => connection.clusterName ===
params.clusterId);
if (params?.type) result = result.filter((c) => c.type === params.type);