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 4f982ccc2 fix(nameserver): enforce canonical names, validate endpoints
and report concurrent mutations (#2510)
4f982ccc2 is described below
commit 4f982ccc2acb3bfc4c4b561f737058574df01287
Author: btlqql <[email protected]>
AuthorDate: Tue Aug 25 17:49:45 2026 +0800
fix(nameserver): enforce canonical names, validate endpoints and report
concurrent mutations (#2510)
* fix(nameserver): enforce canonical registry names
Registry names were not trimmed and rmq_nameserver.name had no unique
constraint, so 'prod' and 'prod ' (or two concurrent 'prod' creates) could
both be stored as near-duplicate entries.
Names are now trimmed at the service boundary (blank becomes a 400), the
reference DDL gains UNIQUE KEY uk_nameserver_name, and unique-index
violations are mapped to 409 so concurrent creates/rename collisions fail
with a conflict instead of an opaque 500.
Fixes #2486
* fix(nameserver): validate registry endpoint addresses
namesrvAddr was only checked for non-empty, so empty segments, missing
ports, out-of-range ports and malformed IPv6 literals could be stored and
only surfaced later as 'offline' during cluster probing.
Add a NamesrvAddrParser that accepts host:port and [IPv6]:port segments
separated by commas or semicolons, rejects empty segments and ports outside
1-65535, and stores the normalized value (trimmed, comma-joined, lowercased
hosts) on create and update.
Fixes #2487
* fix(nameserver): report concurrent registry mutations accurately
Update ignored the affected-row count of updateById and reloaded the row
into toVO unguarded, so an entry deleted in between triggered an NPE/500;
delete likewise ignored the final affected-row count, so two concurrent
deletes could both report success.
Both paths now check the affected rows and return 404 when the row has
disappeared instead of failing opaquely or reporting a false success.
Fixes #2488
---
deploy/mysql/upgrade-nameserver-name-uk.sql | 35 ++++
.../nameserver/NameserverRegistryService.java | 68 ++++++--
.../cluster/nameserver/NamesrvAddrParser.java | 102 ++++++++++++
server/src/main/resources/db/schema.sql | 3 +-
.../nameserver/NameserverRegistryServiceTest.java | 185 +++++++++++++++++++++
.../cluster/nameserver/NamesrvAddrParserTest.java | 126 ++++++++++++++
6 files changed, 506 insertions(+), 13 deletions(-)
diff --git a/deploy/mysql/upgrade-nameserver-name-uk.sql
b/deploy/mysql/upgrade-nameserver-name-uk.sql
new file mode 100644
index 000000000..5427cdb7f
--- /dev/null
+++ b/deploy/mysql/upgrade-nameserver-name-uk.sql
@@ -0,0 +1,35 @@
+-- deploy/mysql/upgrade-nameserver-name-uk.sql
+-- 存量 MySQL 数据卷增量迁移(2026-08-25):rmq_nameserver.name 全局唯一。
+-- 背景:nameserver 注册名作为业务唯一标识;此前无唯一约束,并发创建可能产生重复记录。
+-- 适用:数据卷已初始化、docker-entrypoint-initdb.d 不会再执行的存量部署。
+-- 全新数据卷由 server/src/main/resources/db/schema.sql 直接带上
uk_nameserver_name。
+-- 幂等:可重复执行。
+--
+-- ⚠️ 会删除同名重复记录(保留 created_at 最早的一条)。执行前可先用下面的查询核对重复:
+-- SELECT name, COUNT(*) FROM rmq_nameserver GROUP BY name HAVING COUNT(*) >
1;
+--
+-- 用法(远程容器内执行):
+-- docker exec -i rocketmq-studio-mysql mysql -uroot -pstudio123 rocketmq <
upgrade-nameserver-name-uk.sql
+
+SET NAMES utf8mb4;
+
+-- 1. 清理同名重复(保留 created_at 最早,id 最小作为并列时的决胜)
+DELETE i FROM rmq_nameserver i
+JOIN rmq_nameserver k
+ ON k.name = i.name
+ AND (k.created_at < i.created_at
+ OR (k.created_at = i.created_at AND k.id < i.id));
+
+-- 2. 追加唯一键(仅当不存在时)
+SET @uk_exists := (
+ SELECT COUNT(*) FROM information_schema.statistics
+ WHERE table_schema = DATABASE()
+ AND table_name = 'rmq_nameserver'
+ AND index_name = 'uk_nameserver_name'
+);
+SET @uk_sql := IF(@uk_exists = 0,
+ 'ALTER TABLE rmq_nameserver ADD UNIQUE KEY uk_nameserver_name (name)',
+ 'SELECT ''uk_nameserver_name already exists'' AS msg');
+PREPARE stmt FROM @uk_sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/cluster/nameserver/NameserverRegistryService.java
b/server/src/main/java/org/apache/rocketmq/studio/cluster/nameserver/NameserverRegistryService.java
index 2ecbf2db5..2d2c6e750 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/cluster/nameserver/NameserverRegistryService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/cluster/nameserver/NameserverRegistryService.java
@@ -21,6 +21,7 @@ import lombok.RequiredArgsConstructor;
import org.apache.rocketmq.studio.common.exception.BusinessException;
import org.apache.rocketmq.studio.persistence.entity.RmqNameserver;
import org.apache.rocketmq.studio.persistence.mapper.RmqNameserverMapper;
+import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import java.util.List;
@@ -38,18 +39,24 @@ public class NameserverRegistryService {
}
public NameserverRegistryVO create(CreateNameserverRegistryDTO command) {
+ String name = normalizeName(command.getName());
Long existing = nameserverMapper.selectCount(new
QueryWrapper<RmqNameserver>()
- .eq("name", command.getName()));
+ .eq("name", name));
if (existing != null && existing > 0) {
- throw new BusinessException(400, "NameServer registry name already
exists: " + command.getName());
+ throw duplicateName(name);
}
RmqNameserver entity = new RmqNameserver();
- entity.setName(command.getName());
- entity.setNamesrvAddr(command.getNamesrvAddr());
+ entity.setName(name);
+
entity.setNamesrvAddr(NamesrvAddrParser.normalize(command.getNamesrvAddr()));
entity.setK8sNamespace(command.getK8sNamespace());
entity.setK8sId(command.getK8sId());
entity.setDescription(command.getDescription());
- nameserverMapper.insert(entity);
+ try {
+ nameserverMapper.insert(entity);
+ } catch (DataIntegrityViolationException exception) {
+ // The unique index is the final guard against concurrent
duplicate creates.
+ throw duplicateName(name);
+ }
return toVO(nameserverMapper.selectById(entity.getId()));
}
@@ -58,26 +65,63 @@ public class NameserverRegistryService {
if (entity == null) {
throw new BusinessException(404, "NameServer registry entry not
found: " + command.getId());
}
+ String name = normalizeName(command.getName());
Long duplicates = nameserverMapper.selectCount(new
QueryWrapper<RmqNameserver>()
- .eq("name", command.getName())
+ .eq("name", name)
.ne("id", command.getId()));
if (duplicates != null && duplicates > 0) {
- throw new BusinessException(400, "NameServer registry name already
exists: " + command.getName());
+ throw duplicateName(name);
}
- entity.setName(command.getName());
- entity.setNamesrvAddr(command.getNamesrvAddr());
+ entity.setName(name);
+
entity.setNamesrvAddr(NamesrvAddrParser.normalize(command.getNamesrvAddr()));
entity.setK8sNamespace(command.getK8sNamespace());
entity.setK8sId(command.getK8sId());
entity.setDescription(command.getDescription());
- nameserverMapper.updateById(entity);
- return toVO(nameserverMapper.selectById(entity.getId()));
+ try {
+ int updated = nameserverMapper.updateById(entity);
+ if (updated == 0) {
+ throw concurrentlyDeleted(command.getId());
+ }
+ } catch (DataIntegrityViolationException exception) {
+ // The unique index is the final guard against concurrent rename
collisions.
+ throw duplicateName(name);
+ }
+ RmqNameserver stored = nameserverMapper.selectById(entity.getId());
+ if (stored == null) {
+ // The row vanished between the update and the reload; do not
convert null to a VO.
+ throw concurrentlyDeleted(command.getId());
+ }
+ return toVO(stored);
+ }
+
+ /**
+ * Registry names are compared and displayed as-is, so surrounding
whitespace must not
+ * create near-duplicate entries ("prod" vs "prod ").
+ */
+ private static String normalizeName(String raw) {
+ String name = raw == null ? "" : raw.trim();
+ if (name.isEmpty()) {
+ throw new BusinessException(400, "name must not be blank");
+ }
+ return name;
+ }
+
+ private static BusinessException duplicateName(String name) {
+ return new BusinessException(409, "NameServer registry name already
exists: " + name);
}
public void delete(Long id) {
if (nameserverMapper.selectById(id) == null) {
throw new BusinessException(404, "NameServer registry entry not
found: " + id);
}
- nameserverMapper.deleteById(id);
+ if (nameserverMapper.deleteById(id) == 0) {
+ // A concurrent delete already removed the row; report it instead
of a false success.
+ throw concurrentlyDeleted(id);
+ }
+ }
+
+ private static BusinessException concurrentlyDeleted(Long id) {
+ return new BusinessException(404, "NameServer registry entry was
deleted concurrently: " + id);
}
private NameserverRegistryVO toVO(RmqNameserver entity) {
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/cluster/nameserver/NamesrvAddrParser.java
b/server/src/main/java/org/apache/rocketmq/studio/cluster/nameserver/NamesrvAddrParser.java
new file mode 100644
index 000000000..45bbca3b4
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/cluster/nameserver/NamesrvAddrParser.java
@@ -0,0 +1,102 @@
+/*
+ * 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.cluster.nameserver;
+
+import org.apache.rocketmq.studio.common.exception.BusinessException;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Parses the NameServer address list stored in the registry. Accepts comma or
semicolon
+ * separated segments in {@code host:port} or {@code [IPv6]:port} form and
returns the
+ * normalized value: trimmed segments joined by commas with lowercased hosts.
+ */
+public final class NamesrvAddrParser {
+
+ private static final int MIN_PORT = 1;
+ private static final int MAX_PORT = 65535;
+
+ private NamesrvAddrParser() {
+ }
+
+ public static String normalize(String raw) {
+ if (raw == null || raw.trim().isEmpty()) {
+ throw new BusinessException(400, "namesrvAddr must not be blank");
+ }
+ List<String> segments = new ArrayList<>();
+ for (String part : raw.split("[,;]")) {
+ String segment = part.trim();
+ if (segment.isEmpty()) {
+ throw new BusinessException(400, "namesrvAddr contains an
empty address segment");
+ }
+ segments.add(normalizeSegment(segment));
+ }
+ return String.join(",", segments);
+ }
+
+ private static String normalizeSegment(String segment) {
+ int portStart = segment.lastIndexOf(':');
+ if (portStart <= 0) {
+ throw new BusinessException(400, "namesrvAddr segment is missing
host:port: " + segment);
+ }
+ String host = segment.substring(0, portStart);
+ String portText = segment.substring(portStart + 1);
+ String normalizedHost;
+ if (host.startsWith("[") && host.endsWith("]")) {
+ String ipv6 = host.substring(1, host.length() - 1);
+ if (!isValidIpv6Literal(ipv6)) {
+ throw new BusinessException(400, "namesrvAddr segment has a
malformed IPv6 literal: " + segment);
+ }
+ normalizedHost = "[" + ipv6.toLowerCase() + "]";
+ } else {
+ if (host.isEmpty()) {
+ throw new BusinessException(400, "namesrvAddr segment is
missing a host: " + segment);
+ }
+ if (host.chars().anyMatch(ch -> ch == ':' ||
Character.isWhitespace(ch))) {
+ throw new BusinessException(400, "namesrvAddr segment has an
unexpected character: " + segment);
+ }
+ normalizedHost = host.toLowerCase();
+ }
+ int port;
+ try {
+ port = Integer.parseInt(portText);
+ } catch (NumberFormatException exception) {
+ throw new BusinessException(400, "namesrvAddr segment has a
non-numeric port: " + segment);
+ }
+ if (port < MIN_PORT || port > MAX_PORT) {
+ throw new BusinessException(400, "namesrvAddr port is out of range
1-65535: " + segment);
+ }
+ return normalizedHost + ":" + port;
+ }
+
+ private static boolean isValidIpv6Literal(String ipv6) {
+ if (ipv6.isEmpty() || ipv6.chars().filter(ch -> ch == ':').count() <
2) {
+ return false;
+ }
+ for (char ch : ipv6.toCharArray()) {
+ boolean valid = ch == ':'
+ || Character.isDigit(ch)
+ || ch >= 'a' && ch <= 'f'
+ || ch >= 'A' && ch <= 'F';
+ if (!valid) {
+ return false;
+ }
+ }
+ return true;
+ }
+}
diff --git a/server/src/main/resources/db/schema.sql
b/server/src/main/resources/db/schema.sql
index 9e58a7d78..29a21f063 100644
--- a/server/src/main/resources/db/schema.sql
+++ b/server/src/main/resources/db/schema.sql
@@ -53,7 +53,8 @@ CREATE TABLE IF NOT EXISTS rmq_nameserver (
k8s_id VARCHAR(128) DEFAULT NULL COMMENT 'k8s ID(K8s 部署场景填写,非 K8s 部署留空)',
status VARCHAR(32) DEFAULT 'healthy',
description TEXT,
- PRIMARY KEY (`id`)
+ PRIMARY KEY (`id`),
+ UNIQUE KEY uk_nameserver_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 2. 实例注册表(实例管理页的数据源,topic/group 按 instance_id 归属统计)
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/cluster/nameserver/NameserverRegistryServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/cluster/nameserver/NameserverRegistryServiceTest.java
index ee043a8cb..d1bc1e83e 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/cluster/nameserver/NameserverRegistryServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/cluster/nameserver/NameserverRegistryServiceTest.java
@@ -21,9 +21,11 @@ import
org.apache.rocketmq.studio.persistence.entity.RmqNameserver;
import org.apache.rocketmq.studio.persistence.mapper.RmqNameserverMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.dao.DuplicateKeyException;
import java.time.LocalDateTime;
import java.util.List;
@@ -115,6 +117,89 @@ class NameserverRegistryServiceTest {
verify(nameserverMapper, never()).insert(any(RmqNameserver.class));
}
+ @Test
+ void createShouldTrimNameBeforeUniquenessCheckAndPersistTest() {
+ when(nameserverMapper.selectCount(any())).thenReturn(0L);
+
when(nameserverMapper.insert(any(RmqNameserver.class))).thenAnswer(invocation
-> {
+ RmqNameserver entity = invocation.getArgument(0);
+ entity.setId(10L);
+ return 1;
+ });
+ RmqNameserver stored = new RmqNameserver();
+ stored.setId(10L);
+ stored.setName("prod");
+ when(nameserverMapper.selectById(10L)).thenReturn(stored);
+
+ service.create(CreateNameserverRegistryDTO.builder()
+ .name(" prod ")
+ .namesrvAddr("10.0.0.1:9876")
+ .build());
+
+ ArgumentCaptor<RmqNameserver> captor =
ArgumentCaptor.forClass(RmqNameserver.class);
+ verify(nameserverMapper).insert(captor.capture());
+ assertThat(captor.getValue().getName()).isEqualTo("prod");
+ }
+
+ @Test
+ void createShouldRejectBlankNameTest() {
+ assertThatThrownBy(() ->
service.create(CreateNameserverRegistryDTO.builder()
+ .name(" ")
+ .namesrvAddr("10.0.0.1:9876")
+ .build()))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("must not be blank");
+ verify(nameserverMapper, never()).insert(any(RmqNameserver.class));
+ }
+
+ @Test
+ void createShouldNormalizeAddrBeforePersistTest() {
+ when(nameserverMapper.selectCount(any())).thenReturn(0L);
+
when(nameserverMapper.insert(any(RmqNameserver.class))).thenAnswer(invocation
-> {
+ RmqNameserver entity = invocation.getArgument(0);
+ entity.setId(11L);
+ return 1;
+ });
+ RmqNameserver stored = new RmqNameserver();
+ stored.setId(11L);
+ stored.setName("prod");
+ stored.setNamesrvAddr("ns1:9876,ns2:9876");
+ when(nameserverMapper.selectById(11L)).thenReturn(stored);
+
+ service.create(CreateNameserverRegistryDTO.builder()
+ .name("prod")
+ .namesrvAddr(" NS1:9876 ; ns2:9876 ")
+ .build());
+
+ ArgumentCaptor<RmqNameserver> captor =
ArgumentCaptor.forClass(RmqNameserver.class);
+ verify(nameserverMapper).insert(captor.capture());
+
assertThat(captor.getValue().getNamesrvAddr()).isEqualTo("ns1:9876,ns2:9876");
+ }
+
+ @Test
+ void createShouldRejectMalformedAddrTest() {
+ assertThatThrownBy(() ->
service.create(CreateNameserverRegistryDTO.builder()
+ .name("prod")
+ .namesrvAddr("ns1")
+ .build()))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("missing host:port");
+ verify(nameserverMapper, never()).insert(any(RmqNameserver.class));
+ }
+
+ @Test
+ void createShouldMapUniqueIndexViolationToConflictTest() {
+ when(nameserverMapper.selectCount(any())).thenReturn(0L);
+ when(nameserverMapper.insert(any(RmqNameserver.class)))
+ .thenThrow(new DuplicateKeyException("uk_nameserver_name"));
+
+ assertThatThrownBy(() ->
service.create(CreateNameserverRegistryDTO.builder()
+ .name("prod")
+ .namesrvAddr("10.0.0.1:9876")
+ .build()))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("already exists");
+ }
+
@Test
void updateShouldPersistAndReturnStoredEntryTest() {
RmqNameserver existing = new RmqNameserver();
@@ -122,6 +207,7 @@ class NameserverRegistryServiceTest {
existing.setName("rocketmq1");
when(nameserverMapper.selectById(1L)).thenReturn(existing).thenReturn(existing);
when(nameserverMapper.selectCount(any())).thenReturn(0L);
+
when(nameserverMapper.updateById(any(RmqNameserver.class))).thenReturn(1);
NameserverRegistryVO updated =
service.update(UpdateNameserverRegistryDTO.builder()
.id(1L)
@@ -166,17 +252,116 @@ class NameserverRegistryServiceTest {
verify(nameserverMapper, never()).updateById(any(RmqNameserver.class));
}
+ @Test
+ void updateShouldRejectMalformedAddrTest() {
+ RmqNameserver existing = new RmqNameserver();
+ existing.setId(1L);
+ when(nameserverMapper.selectById(1L)).thenReturn(existing);
+
+ assertThatThrownBy(() ->
service.update(UpdateNameserverRegistryDTO.builder()
+ .id(1L)
+ .name("rocketmq1")
+ .namesrvAddr("ns1:0")
+ .build()))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("out of range");
+ verify(nameserverMapper, never()).updateById(any(RmqNameserver.class));
+ }
+
+ @Test
+ void updateShouldTrimNameBeforePersistTest() {
+ RmqNameserver existing = new RmqNameserver();
+ existing.setId(1L);
+ existing.setName("rocketmq1");
+
when(nameserverMapper.selectById(1L)).thenReturn(existing).thenReturn(existing);
+ when(nameserverMapper.selectCount(any())).thenReturn(0L);
+
when(nameserverMapper.updateById(any(RmqNameserver.class))).thenReturn(1);
+
+ service.update(UpdateNameserverRegistryDTO.builder()
+ .id(1L)
+ .name(" rocketmq1 ")
+ .namesrvAddr("rocketmq1-nameserver.svc:9876")
+ .build());
+
+ assertThat(existing.getName()).isEqualTo("rocketmq1");
+ }
+
+ @Test
+ void updateShouldMapUniqueIndexViolationToConflictTest() {
+ RmqNameserver existing = new RmqNameserver();
+ existing.setId(1L);
+ when(nameserverMapper.selectById(1L)).thenReturn(existing);
+ when(nameserverMapper.selectCount(any())).thenReturn(0L);
+ when(nameserverMapper.updateById(any(RmqNameserver.class)))
+ .thenThrow(new DuplicateKeyException("uk_nameserver_name"));
+
+ assertThatThrownBy(() ->
service.update(UpdateNameserverRegistryDTO.builder()
+ .id(1L)
+ .name("rocketmq2")
+ .namesrvAddr("x:9876")
+ .build()))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("already exists");
+ }
+
+ @Test
+ void updateShouldThrowWhenEntryDeletedAfterReadTest() {
+ RmqNameserver existing = new RmqNameserver();
+ existing.setId(1L);
+ when(nameserverMapper.selectById(1L)).thenReturn(existing);
+ when(nameserverMapper.selectCount(any())).thenReturn(0L);
+
when(nameserverMapper.updateById(any(RmqNameserver.class))).thenReturn(0);
+
+ assertThatThrownBy(() ->
service.update(UpdateNameserverRegistryDTO.builder()
+ .id(1L)
+ .name("rocketmq1")
+ .namesrvAddr("x:9876")
+ .build()))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("deleted concurrently");
+ }
+
+ @Test
+ void updateShouldThrowWhenEntryVanishesBeforeReloadTest() {
+ RmqNameserver existing = new RmqNameserver();
+ existing.setId(1L);
+
when(nameserverMapper.selectById(1L)).thenReturn(existing).thenReturn(null);
+ when(nameserverMapper.selectCount(any())).thenReturn(0L);
+
when(nameserverMapper.updateById(any(RmqNameserver.class))).thenReturn(1);
+
+ assertThatThrownBy(() ->
service.update(UpdateNameserverRegistryDTO.builder()
+ .id(1L)
+ .name("rocketmq1")
+ .namesrvAddr("x:9876")
+ .build()))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("deleted concurrently");
+ }
+
@Test
void deleteShouldRemoveExistingEntryTest() {
RmqNameserver stored = new RmqNameserver();
stored.setId(1L);
when(nameserverMapper.selectById(1L)).thenReturn(stored);
+ when(nameserverMapper.deleteById(1L)).thenReturn(1);
service.delete(1L);
verify(nameserverMapper).deleteById(1L);
}
+ @Test
+ void deleteShouldThrowWhenEntryDeletedConcurrentlyTest() {
+ RmqNameserver stored = new RmqNameserver();
+ stored.setId(1L);
+ when(nameserverMapper.selectById(1L)).thenReturn(stored);
+ when(nameserverMapper.deleteById(1L)).thenReturn(0);
+
+ assertThatThrownBy(() -> service.delete(1L))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("deleted concurrently");
+ }
+
@Test
void deleteShouldThrowWhenEntryMissingTest() {
when(nameserverMapper.selectById(404L)).thenReturn(null);
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/cluster/nameserver/NamesrvAddrParserTest.java
b/server/src/test/java/org/apache/rocketmq/studio/cluster/nameserver/NamesrvAddrParserTest.java
new file mode 100644
index 000000000..71f1dd6fa
--- /dev/null
+++
b/server/src/test/java/org/apache/rocketmq/studio/cluster/nameserver/NamesrvAddrParserTest.java
@@ -0,0 +1,126 @@
+/*
+ * 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.cluster.nameserver;
+
+import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class NamesrvAddrParserTest {
+
+ @Test
+ void keepsWellFormedSingleAddressTest() {
+
assertThat(NamesrvAddrParser.normalize("10.0.0.1:9876")).isEqualTo("10.0.0.1:9876");
+ }
+
+ @Test
+ void lowercasesHostsTest() {
+
assertThat(NamesrvAddrParser.normalize("NS1.Example.COM:9876")).isEqualTo("ns1.example.com:9876");
+ }
+
+ @Test
+ void normalizesSeparatorsAndWhitespaceTest() {
+ assertThat(NamesrvAddrParser.normalize(" ns1:9876 ; ns2:9876 ,ns3:9876
"))
+ .isEqualTo("ns1:9876,ns2:9876,ns3:9876");
+ }
+
+ @Test
+ void supportsIpv6LiteralsTest() {
+
assertThat(NamesrvAddrParser.normalize("[2001:DB8::1]:9876")).isEqualTo("[2001:db8::1]:9876");
+ }
+
+ @Test
+ void rejectsBlankInputTest() {
+ assertThatThrownBy(() -> NamesrvAddrParser.normalize(" "))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("must not be blank");
+ }
+
+ @Test
+ void rejectsEmptySegmentsTest() {
+ assertThatThrownBy(() ->
NamesrvAddrParser.normalize("ns1:9876,,ns2:9876"))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("empty address segment");
+ }
+
+ @Test
+ void rejectsConsecutiveSeparatorsTest() {
+ assertThatThrownBy(() ->
NamesrvAddrParser.normalize("ns1:9876;;ns2:9876"))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("empty address segment");
+ }
+
+ @Test
+ void rejectsMissingPortTest() {
+ assertThatThrownBy(() -> NamesrvAddrParser.normalize("ns1"))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("missing host:port");
+ }
+
+ @Test
+ void rejectsMissingHostTest() {
+ assertThatThrownBy(() -> NamesrvAddrParser.normalize(":9876"))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("missing host:port");
+ }
+
+ @Test
+ void rejectsNonNumericPortTest() {
+ assertThatThrownBy(() -> NamesrvAddrParser.normalize("ns1:abc"))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("non-numeric port");
+ }
+
+ @Test
+ void rejectsPortsOutOfRangeTest() {
+ assertThatThrownBy(() -> NamesrvAddrParser.normalize("ns1:0"))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("out of range");
+ assertThatThrownBy(() -> NamesrvAddrParser.normalize("ns1:65536"))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("out of range");
+ }
+
+ @Test
+ void rejectsUnexpectedColonInHostTest() {
+ assertThatThrownBy(() -> NamesrvAddrParser.normalize("a:b:9876"))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("unexpected character");
+ }
+
+ @Test
+ void rejectsWhitespaceInsideHostTest() {
+ assertThatThrownBy(() -> NamesrvAddrParser.normalize("ns 1:9876"))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("unexpected character");
+ }
+
+ @Test
+ void rejectsMalformedIpv6LiteralsTest() {
+ assertThatThrownBy(() -> NamesrvAddrParser.normalize("[abc]:9876"))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("malformed IPv6");
+ assertThatThrownBy(() -> NamesrvAddrParser.normalize("[1:2]:9876"))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("malformed IPv6");
+ assertThatThrownBy(() -> NamesrvAddrParser.normalize("[::1-g]:9876"))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("malformed IPv6");
+ }
+}