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();

Reply via email to