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 6066c4f60 fix(instance): protect cloud credential references (#2752)
6066c4f60 is described below
commit 6066c4f60d3a91ebd2429fdc9190763693973ca7
Author: aias00 <[email protected]>
AuthorDate: Fri Sep 4 11:59:53 2026 +0800
fix(instance): protect cloud credential references (#2752)
Cloud instance creation and credential deletion can interleave across the
application-level reference check. Add a database foreign key, startup
migration for existing clean schemas, orphan detection for dirty schemas, and
409 mappings for the two race outcomes.
Constraint: Keep the fix on the existing MyBatis/H2/MySQL schema pattern
without adding a migration framework.
Rejected: Locking only in services | database clients could still leave
orphaned references.
Confidence: high
Scope-risk: moderate
Tested: mvn -q test; targeted schema/service tests; MySQL 8 schema
execution.
Signed-off-by: liuhy <[email protected]>
---
.../rocketmq/studio/instance/InstanceService.java | 31 ++++-
.../credential/CloudCredentialSchemaMigration.java | 127 +++++++++++++++++++++
.../credential/CloudCredentialService.java | 9 +-
server/src/main/resources/db/schema.sql | 34 +++---
.../studio/instance/InstanceServiceTest.java | 57 +++++++++
.../persistence/DemoDataSqlCompatibilityTest.java | 13 +++
.../CloudCredentialSchemaMigrationTest.java | 97 ++++++++++++++++
.../credential/CloudCredentialServiceTest.java | 19 +++
8 files changed, 369 insertions(+), 18 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java
index f842c6b2f..e1a2faec1 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java
@@ -39,6 +39,7 @@ import org.apache.rocketmq.studio.settings.SettingsRepository;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -194,7 +195,15 @@ public class InstanceService {
requireUniqueInstanceName(instance.getName(), null);
instance.setGmtCreate(LocalDateTime.now());
instance.setGmtModified(LocalDateTime.now());
- InstanceVO saved = instanceRepository.save(instance);
+ InstanceVO saved;
+ try {
+ saved = instanceRepository.save(instance);
+ } catch (DataIntegrityViolationException exception) {
+ if (vendor != InstanceVendor.APACHE &&
isCloudCredentialReferenceViolation(exception)) {
+ throw new BusinessException(409, "Cloud credential no longer
exists: " + instance.getCredentialId());
+ }
+ throw exception;
+ }
recordAudit("CREATE_INSTANCE", "INSTANCE",
String.valueOf(saved.getId()), null,
instanceAuditDetail(saved));
return saved;
@@ -681,6 +690,26 @@ public class InstanceService {
return "name=" + instance.getName() + ", vendor=" + vendor + ", type="
+ instance.getType();
}
+ private boolean isCloudCredentialReferenceViolation(Throwable exception) {
+ Throwable current = exception;
+ while (current != null) {
+ String message = current.getMessage();
+ if (message != null) {
+ String lower = message.toLowerCase(Locale.ROOT);
+ boolean foreignKeyFailure = lower.contains("foreign key")
+ || lower.contains("referential integrity");
+ boolean credentialReference = lower.contains("credential_id")
+ || lower.contains("rmq_cloud_credential");
+ if (lower.contains("fk_instance_cloud_credential")
+ || foreignKeyFailure && credentialReference) {
+ return true;
+ }
+ }
+ current = current.getCause();
+ }
+ return false;
+ }
+
private InstanceVO copyOf(InstanceVO instance) {
InstanceVO copy = InstanceVO.builder()
.name(instance.getName())
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialSchemaMigration.java
b/server/src/main/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialSchemaMigration.java
new file mode 100644
index 000000000..22857416e
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialSchemaMigration.java
@@ -0,0 +1,127 @@
+/*
+ * 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.provider.credential;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.ApplicationArguments;
+import org.springframework.boot.ApplicationRunner;
+import org.springframework.stereotype.Component;
+
+import javax.sql.DataSource;
+import java.sql.Connection;
+import java.sql.DatabaseMetaData;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+
+/** Adds referential integrity for cloud instances created by earlier Studio
builds. */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class CloudCredentialSchemaMigration implements ApplicationRunner {
+ static final String INSTANCE_TABLE = "rmq_instance";
+ static final String CREDENTIAL_TABLE = "rmq_cloud_credential";
+ static final String CREDENTIAL_INDEX = "idx_instance_credential_id";
+ static final String CREDENTIAL_FK = "fk_instance_cloud_credential";
+
+ private final DataSource dataSource;
+
+ @Override
+ public void run(ApplicationArguments args) throws Exception {
+ try (Connection connection = dataSource.getConnection(); Statement
statement = connection.createStatement()) {
+ DatabaseMetaData metadata = connection.getMetaData();
+ String catalog = connection.getCatalog();
+ if (!hasTable(metadata, catalog, INSTANCE_TABLE) ||
!hasTable(metadata, catalog, CREDENTIAL_TABLE)) {
+ return;
+ }
+ if (hasImportedKey(metadata, catalog)) {
+ return;
+ }
+ long orphanCount = countOrphanedCredentialReferences(statement);
+ if (orphanCount > 0) {
+ throw new IllegalStateException("Cannot add cloud credential
foreign key because " + orphanCount
+ + " instances reference missing cloud credentials");
+ }
+ ensureIndex(metadata, catalog, statement);
+ addForeignKey(metadata, catalog, statement);
+ }
+ }
+
+ private static long countOrphanedCredentialReferences(Statement statement)
throws SQLException {
+ try (ResultSet result = statement.executeQuery("SELECT COUNT(*) FROM "
+ INSTANCE_TABLE
+ + " i LEFT JOIN " + CREDENTIAL_TABLE + " c ON c.id =
i.credential_id"
+ + " WHERE i.credential_id IS NOT NULL AND c.id IS NULL")) {
+ result.next();
+ return result.getLong(1);
+ }
+ }
+
+ private static void ensureIndex(DatabaseMetaData metadata, String catalog,
Statement statement) throws Exception {
+ if (hasIndex(metadata, catalog, CREDENTIAL_INDEX)) {
+ return;
+ }
+ try {
+ log.info("Adding cloud credential reference index {}.{}",
INSTANCE_TABLE, CREDENTIAL_INDEX);
+ statement.executeUpdate("CREATE INDEX " + CREDENTIAL_INDEX + " ON
" + INSTANCE_TABLE + " (credential_id)");
+ } catch (SQLException failure) {
+ if (!hasIndex(metadata, catalog, CREDENTIAL_INDEX)) {
+ throw failure;
+ }
+ }
+ }
+
+ private static void addForeignKey(DatabaseMetaData metadata, String
catalog, Statement statement) throws Exception {
+ try {
+ log.info("Adding cloud credential foreign key {}.{}",
INSTANCE_TABLE, CREDENTIAL_FK);
+ statement.executeUpdate("ALTER TABLE " + INSTANCE_TABLE + " ADD
CONSTRAINT " + CREDENTIAL_FK
+ + " FOREIGN KEY (credential_id) REFERENCES " +
CREDENTIAL_TABLE + " (id) ON DELETE RESTRICT");
+ } catch (SQLException failure) {
+ if (!hasImportedKey(metadata, catalog)) {
+ throw failure;
+ }
+ }
+ }
+
+ private static boolean hasTable(DatabaseMetaData metadata, String catalog,
String table) throws Exception {
+ try (ResultSet tables = metadata.getTables(catalog, null, table, new
String[] {"TABLE"})) {
+ return tables.next();
+ }
+ }
+
+ private static boolean hasIndex(DatabaseMetaData metadata, String catalog,
String index) throws Exception {
+ try (ResultSet indexes = metadata.getIndexInfo(catalog, null,
INSTANCE_TABLE, false, false)) {
+ while (indexes.next()) {
+ if (index.equalsIgnoreCase(indexes.getString("INDEX_NAME"))) {
+ return true;
+ }
+ }
+ return false;
+ }
+ }
+
+ private static boolean hasImportedKey(DatabaseMetaData metadata, String
catalog) throws Exception {
+ try (ResultSet keys = metadata.getImportedKeys(catalog, null,
INSTANCE_TABLE)) {
+ while (keys.next()) {
+ if (CREDENTIAL_FK.equalsIgnoreCase(keys.getString("FK_NAME")))
{
+ return true;
+ }
+ }
+ return false;
+ }
+ }
+}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialService.java
b/server/src/main/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialService.java
index e8e6d6d71..3fe023f03 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialService.java
@@ -27,6 +27,7 @@ import org.apache.rocketmq.studio.instance.InstanceRepository;
import org.apache.rocketmq.studio.provider.alibaba.AliyunClientFactory;
import org.apache.rocketmq.studio.provider.tencent.TencentClientFactory;
import org.springframework.stereotype.Service;
+import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.DuplicateKeyException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -130,8 +131,12 @@ public class CloudCredentialService {
if (instanceRepository.existsByCredentialId(id)) {
throw new BusinessException(400, "Cloud credential is referenced
by existing instances");
}
- if (!credentialRepository.deleteById(id)) {
- throw new BusinessException(404, "Cloud credential not found: " +
id);
+ try {
+ if (!credentialRepository.deleteById(id)) {
+ throw new BusinessException(404, "Cloud credential not found:
" + id);
+ }
+ } catch (DataIntegrityViolationException exception) {
+ throw new BusinessException(409, "Cloud credential is referenced
by existing instances");
}
invalidateCloudClients(existing);
recordAudit("DELETE_CLOUD_CREDENTIAL", "CLOUD_CREDENTIAL",
String.valueOf(id), null,
diff --git a/server/src/main/resources/db/schema.sql
b/server/src/main/resources/db/schema.sql
index 38b6cf44c..2e62cf379 100644
--- a/server/src/main/resources/db/schema.sql
+++ b/server/src/main/resources/db/schema.sql
@@ -57,6 +57,21 @@ CREATE TABLE IF NOT EXISTS rmq_nameserver (
UNIQUE KEY uk_nameserver_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+-- Cloud provider credentials (defined before rmq_instance for the FK below;
+-- secret_key is base64-encoded and never seeded).
+CREATE TABLE IF NOT EXISTS rmq_cloud_credential (
+ `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
+ `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ `gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE
CURRENT_TIMESTAMP COMMENT '修改时间',
+ name VARCHAR(128) NOT NULL COMMENT 'Credential display name',
+ vendor VARCHAR(32) NOT NULL COMMENT 'ALIYUN/TENCENT',
+ access_key VARCHAR(255) NOT NULL,
+ secret_key VARCHAR(512) NOT NULL COMMENT 'Base64-encoded secret key',
+ remark VARCHAR(255),
+ PRIMARY KEY (`id`),
+ UNIQUE KEY uk_vendor_access_key (vendor, access_key)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
-- 2. 实例注册表(实例管理页的数据源,topic/group 按 instance_id 归属统计)
CREATE TABLE IF NOT EXISTS rmq_instance (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
@@ -72,7 +87,10 @@ CREATE TABLE IF NOT EXISTS rmq_instance (
admin_credential_ref VARCHAR(128) COMMENT 'External Apache admin credential
reference; no secret material',
region_id VARCHAR(128),
PRIMARY KEY (`id`),
- UNIQUE KEY uk_instance_name (name)
+ UNIQUE KEY uk_instance_name (name),
+ INDEX idx_instance_credential_id (credential_id),
+ CONSTRAINT fk_instance_cloud_credential FOREIGN KEY (credential_id)
+ REFERENCES rmq_cloud_credential(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 3. Topic 管理记录(通过 Studio 创建/管理的 Topic 元数据)
@@ -396,20 +414,6 @@ CREATE TABLE IF NOT EXISTS rmq_system_alert (
INDEX idx_system_alert_feed (domain, instance_id, transition, time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
--- 15. Cloud provider credentials (secret_key is base64-encoded and never
seeded).
-CREATE TABLE IF NOT EXISTS rmq_cloud_credential (
- `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
- `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
- `gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE
CURRENT_TIMESTAMP COMMENT '修改时间',
- name VARCHAR(128) NOT NULL COMMENT 'Credential display name',
- vendor VARCHAR(32) NOT NULL COMMENT 'ALIYUN/TENCENT',
- access_key VARCHAR(255) NOT NULL,
- secret_key VARCHAR(512) NOT NULL COMMENT 'Base64-encoded secret key',
- remark VARCHAR(255),
- PRIMARY KEY (`id`),
- UNIQUE KEY uk_vendor_access_key (vendor, access_key)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-
-- Idempotent upgrades for databases created before the corresponding CREATE
statements
-- were widened. Safe to re-run: on fresh databases the columns already match,
and on
-- existing databases the MODIFY below only grows the column width. Usernames
may be up
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
index 8c01617f4..9367ab4c9 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
@@ -40,6 +40,7 @@ import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.dao.DataIntegrityViolationException;
import java.time.LocalDateTime;
import java.util.ArrayList;
@@ -1521,6 +1522,62 @@ class InstanceServiceTest {
assertThat(created.getVendor()).isEqualTo(InstanceVendor.TENCENT);
}
+ @Test
+ void
createCloudInstanceShouldTranslateDeletedCredentialDuringSaveToConflictTest() {
+ InstanceVO instance = InstanceVO.builder()
+ .vendor(InstanceVendor.ALIYUN)
+ .credentialId(1L)
+ .cloudInstanceId("rmq-cn-race")
+ .regionId("cn-hangzhou")
+ .build();
+ CloudCredentialVO credential = new CloudCredentialVO();
+ credential.setId(1L);
+ credential.setVendor(InstanceVendor.ALIYUN);
+
when(cloudCredentialRepository.findById(1L)).thenReturn(Optional.of(credential));
+ CloudCatalogProvider catalog =
org.mockito.Mockito.mock(CloudCatalogProvider.class);
+ CloudInstanceDetailVO detail = new CloudInstanceDetailVO();
+ detail.setInstanceId("rmq-cn-race");
+ detail.setEndpoints(List.of(new
CloudInstanceDetailVO.CloudEndpoint("TCP_VPC", "vpc:8080")));
+
when(providerRegistry.catalogFor(InstanceVendor.ALIYUN)).thenReturn(catalog);
+ when(catalog.getCloudInstance(1L, "cn-hangzhou",
"rmq-cn-race")).thenReturn(detail);
+ when(instanceRepository.save(any(InstanceVO.class)))
+ .thenThrow(new DataIntegrityViolationException(
+ "foreign key constraint
fk_instance_cloud_credential"));
+
+ assertThatThrownBy(() -> instanceService.createInstance(instance))
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("Cloud credential no longer exists: 1")
+ .satisfies(error -> assertThat(((BusinessException)
error).getCode()).isEqualTo(409));
+
+ verify(operationAuditService, never()).record(anyString(),
anyString(), anyString(), any(), anyString(),
+ anyString(), any());
+ }
+
+ @Test
+ void
createCloudInstanceShouldNotTranslateUnrelatedForeignKeyViolationTest() {
+ InstanceVO instance = InstanceVO.builder()
+ .vendor(InstanceVendor.ALIYUN)
+ .credentialId(1L)
+ .cloudInstanceId("rmq-cn-race")
+ .regionId("cn-hangzhou")
+ .build();
+ CloudCredentialVO credential = new CloudCredentialVO();
+ credential.setId(1L);
+ credential.setVendor(InstanceVendor.ALIYUN);
+
when(cloudCredentialRepository.findById(1L)).thenReturn(Optional.of(credential));
+ CloudCatalogProvider catalog =
org.mockito.Mockito.mock(CloudCatalogProvider.class);
+ CloudInstanceDetailVO detail = new CloudInstanceDetailVO();
+ detail.setInstanceId("rmq-cn-race");
+ detail.setEndpoints(List.of(new
CloudInstanceDetailVO.CloudEndpoint("TCP_VPC", "vpc:8080")));
+
when(providerRegistry.catalogFor(InstanceVendor.ALIYUN)).thenReturn(catalog);
+ when(catalog.getCloudInstance(1L, "cn-hangzhou",
"rmq-cn-race")).thenReturn(detail);
+ DataIntegrityViolationException violation = new
DataIntegrityViolationException(
+ "foreign key constraint fk_instance_owner");
+
when(instanceRepository.save(any(InstanceVO.class))).thenThrow(violation);
+
+ assertThatThrownBy(() ->
instanceService.createInstance(instance)).isSameAs(violation);
+ }
+
@Test
void updateInstanceShouldKeepCloudFieldsImmutableTest() {
InstanceVO existing = InstanceVO.builder()
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/persistence/DemoDataSqlCompatibilityTest.java
b/server/src/test/java/org/apache/rocketmq/studio/persistence/DemoDataSqlCompatibilityTest.java
index d70a307e0..2f43f2376 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/persistence/DemoDataSqlCompatibilityTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/persistence/DemoDataSqlCompatibilityTest.java
@@ -59,6 +59,7 @@ class DemoDataSqlCompatibilityTest {
assertThat(allIdsAreNumeric(connection, "rmq_instance")).isTrue();
assertThat(allIdsAreNumeric(connection, "rmq_acl_rule")).isTrue();
assertThat(allIdsAreNumeric(connection, "rmq_acl_user")).isTrue();
+ assertThat(hasImportedKey(connection, "rmq_instance",
"fk_instance_cloud_credential")).isTrue();
}
}
@@ -162,4 +163,16 @@ class DemoDataSqlCompatibilityTest {
return columns.next();
}
}
+
+ private static boolean hasImportedKey(Connection connection, String table,
String foreignKeyName)
+ throws SQLException {
+ try (ResultSet keys = connection.getMetaData().getImportedKeys(null,
null, table)) {
+ while (keys.next()) {
+ if
(foreignKeyName.equalsIgnoreCase(keys.getString("FK_NAME"))) {
+ return true;
+ }
+ }
+ return false;
+ }
+ }
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialSchemaMigrationTest.java
b/server/src/test/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialSchemaMigrationTest.java
new file mode 100644
index 000000000..9408fa149
--- /dev/null
+++
b/server/src/test/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialSchemaMigrationTest.java
@@ -0,0 +1,97 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.provider.credential;
+
+import org.h2.jdbcx.JdbcDataSource;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.DefaultApplicationArguments;
+
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.Statement;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class CloudCredentialSchemaMigrationTest {
+
+ @Test
+ void migrationShouldAddCredentialForeignKeyToExistingCleanDatabaseTest()
throws Exception {
+ JdbcDataSource dataSource = dataSource("clean");
+ try (Connection connection = dataSource.getConnection(); Statement
statement = connection.createStatement()) {
+ createLegacyTables(statement);
+ statement.executeUpdate("INSERT INTO rmq_cloud_credential (id,
name, vendor, access_key, secret_key)"
+ + " VALUES (1, 'aliyun', 'ALIYUN', 'ak', 'sk')");
+ statement.executeUpdate("INSERT INTO rmq_instance (id, name, type,
endpoint, vendor, credential_id)"
+ + " VALUES (10, 'cloud-a', 'CLOUD', 'endpoint', 'ALIYUN',
1)");
+ }
+
+ CloudCredentialSchemaMigration migration = new
CloudCredentialSchemaMigration(dataSource);
+ migration.run(new DefaultApplicationArguments());
+ migration.run(new DefaultApplicationArguments());
+
+ try (Connection connection = dataSource.getConnection()) {
+ assertThat(hasImportedKey(connection)).isTrue();
+ assertThatThrownBy(() -> {
+ try (Statement statement = connection.createStatement()) {
+ statement.executeUpdate("DELETE FROM rmq_cloud_credential
WHERE id = 1");
+ }
+ }).hasMessageContaining("constraint");
+ }
+ }
+
+ @Test
+ void
migrationShouldRejectOrphanedCredentialReferencesBeforeAddingForeignKeyTest()
throws Exception {
+ JdbcDataSource dataSource = dataSource("orphan");
+ try (Connection connection = dataSource.getConnection(); Statement
statement = connection.createStatement()) {
+ createLegacyTables(statement);
+ statement.executeUpdate("INSERT INTO rmq_instance (id, name, type,
endpoint, vendor, credential_id)"
+ + " VALUES (10, 'cloud-a', 'CLOUD', 'endpoint', 'ALIYUN',
99)");
+ }
+
+ CloudCredentialSchemaMigration migration = new
CloudCredentialSchemaMigration(dataSource);
+
+ assertThatThrownBy(() -> migration.run(new
DefaultApplicationArguments()))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("missing cloud credentials")
+ .hasMessageContaining("1");
+ }
+
+ private static JdbcDataSource dataSource(String name) {
+ JdbcDataSource dataSource = new JdbcDataSource();
+ dataSource.setURL("jdbc:h2:mem:cloud-credential-schema-" + name
+ + ";MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE");
+ dataSource.setUser("sa");
+ return dataSource;
+ }
+
+ private static void createLegacyTables(Statement statement) throws
Exception {
+ statement.execute("CREATE TABLE rmq_instance ("
+ + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
+ + "gmt_create DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "
+ + "gmt_modified DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON
UPDATE CURRENT_TIMESTAMP, "
+ + "name VARCHAR(128) NOT NULL, type VARCHAR(32) NOT NULL,
endpoint VARCHAR(512) NOT NULL, "
+ + "vendor VARCHAR(32), credential_id BIGINT)");
+ statement.execute("CREATE TABLE rmq_cloud_credential ("
+ + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
+ + "gmt_create DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "
+ + "gmt_modified DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON
UPDATE CURRENT_TIMESTAMP, "
+ + "name VARCHAR(128) NOT NULL, vendor VARCHAR(32) NOT NULL,
access_key VARCHAR(255) NOT NULL, "
+ + "secret_key VARCHAR(512) NOT NULL)");
+ }
+
+ private static boolean hasImportedKey(Connection connection) throws
Exception {
+ try (ResultSet keys = connection.getMetaData().getImportedKeys(null,
null, "rmq_instance")) {
+ while (keys.next()) {
+ if
("fk_instance_cloud_credential".equalsIgnoreCase(keys.getString("FK_NAME"))) {
+ return true;
+ }
+ }
+ return false;
+ }
+ }
+}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialServiceTest.java
index 9d84b129c..05ebadda8 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialServiceTest.java
@@ -28,6 +28,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.DuplicateKeyException;
import java.util.List;
@@ -248,6 +249,24 @@ class CloudCredentialServiceTest {
verify(aliyunClientFactory, never()).invalidateCredential(any());
}
+ @Test
+ void deleteShouldTranslateConcurrentInstanceReferenceToConflictTest() {
+ CloudCredentialVO stored = new CloudCredentialVO();
+ stored.setId(1L);
+ stored.setVendor(InstanceVendor.ALIYUN);
+
when(credentialRepository.findById(1L)).thenReturn(Optional.of(stored));
+ when(instanceRepository.existsByCredentialId(1L)).thenReturn(false);
+ when(credentialRepository.deleteById(1L))
+ .thenThrow(new DataIntegrityViolationException("foreign key
constraint"));
+
+ assertThatThrownBy(() -> service.delete(1L))
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("Cloud credential is referenced by existing
instances")
+ .satisfies(error -> assertThat(((BusinessException)
error).getCode()).isEqualTo(409));
+
+ verify(aliyunClientFactory, never()).invalidateCredential(any());
+ }
+
@Test
void revealShouldReturnUnmaskedCredentialTest() {
CloudCredentialVO stored = new CloudCredentialVO();