diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAiFlywayMigrationIT.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAiFlywayMigrationIT.java
index 17983e35..6a2f73f0 100644
--- a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAiFlywayMigrationIT.java
+++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAiFlywayMigrationIT.java
@@ -4,6 +4,7 @@ import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.MigrationInfo;
import org.flywaydb.core.api.output.MigrateResult;
import org.junit.jupiter.api.Test;
+import org.slf4j.LoggerFactory;
import java.sql.Connection;
import java.sql.DriverManager;
@@ -15,8 +16,10 @@ import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
import java.util.Objects;
+import java.util.Set;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -26,7 +29,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* P1R-4 AI 真实 PostgreSQL / Flyway 迁移验收。
*
- *
本测试只接受显式传入的 p1r.flyway.* 参数,避免误连默认开发库;
+ *
本测试只接受显式传入的 Flyway URL/用户/位置参数与环境变量密码,避免误连默认开发库;
* 运行前会清理当前隔离测试库的 public schema,再执行 sql/muse 下 V1-V13 全量迁移。
*/
class P1rAiFlywayMigrationIT {
@@ -53,17 +56,35 @@ class P1rAiFlywayMigrationIT {
"idx_muse_ai_runtime_payload_active",
"idx_muse_ai_runtime_payload_cleanup"
);
+ private static final Set CREDENTIAL_QUERY_KEYS = Set.of(
+ "user",
+ "username",
+ "password",
+ "pass",
+ "pwd",
+ "sslpassword",
+ "ssl_password",
+ "token",
+ "secret",
+ "api_key",
+ "apikey",
+ "bearer",
+ "access_token",
+ "refresh_token"
+ );
@Test
void should_migrateV1ToV13OnRealPostgresqlAndVerifyAiTablesAndIndexes() throws Exception {
String url = requiredProperty("p1r.flyway.url");
String user = requiredProperty("p1r.flyway.user");
- String password = requiredSecret("p1r.flyway.password", "P1R_FLYWAY_PASSWORD", "MUSE_POSTGRES_PASSWORD");
+ String password = requiredPasswordEnvironment();
String requestedLocations = System.getProperty("p1r.flyway.locations", "filesystem:sql/muse");
assertEquals("filesystem:sql/muse", requestedLocations,
"Task 11 要求 Flyway locations 显式使用 filesystem:sql/muse");
String effectiveLocations = resolveMuseSqlLocation(requestedLocations);
+ silenceFlywayInfoLogs();
+ assertNoCredentialQuery(url);
// WHY:Task 11 只允许使用隔离 _test 库;这里用测试侧硬闸避免误清理真实开发库。
assertTrue(url.matches(".*/[^/?]*_test(?:[?].*)?$"),
"p1r.flyway.url 必须指向 _test 后缀隔离库,避免清理非测试库: " + maskedUrl(url));
@@ -73,6 +94,7 @@ class P1rAiFlywayMigrationIT {
.locations(effectiveLocations)
.schemas("public")
.defaultSchema("public")
+ .target(TARGET_VERSION)
.cleanDisabled(false)
.load();
@@ -115,19 +137,21 @@ class P1rAiFlywayMigrationIT {
return value;
}
- private static String requiredSecret(String propertyName, String... envNames) {
- String value = System.getProperty(propertyName);
- if (value != null && !value.isBlank()) {
- return value;
- }
- for (String envName : envNames) {
- value = System.getenv(envName);
+ private static String requiredPasswordEnvironment() {
+ String password = firstNonBlankEnvironment("P1R_FLYWAY_PASSWORD", "MUSE_POSTGRES_PASSWORD");
+ assertTrue(password != null, "缺少必需数据库密码环境变量");
+ return password;
+ }
+
+ private static String firstNonBlankEnvironment(String... names) {
+ // WHY:数据库密码不能通过 JVM system property 传入,否则 Surefire XML 会记录属性名和值。
+ for (String name : names) {
+ String value = System.getenv(name);
if (value != null && !value.isBlank()) {
return value;
}
}
- assertTrue(false, "缺少必需系统属性或环境变量: " + propertyName + "/" + String.join("/", envNames));
- return "";
+ return null;
}
private static String resolveMuseSqlLocation(String requestedLocations) {
@@ -141,6 +165,17 @@ class P1rAiFlywayMigrationIT {
throw new IllegalStateException("无法从当前目录向上找到 sql/muse: " + current);
}
+ private static void silenceFlywayInfoLogs() {
+ try {
+ Object flywayLogger = LoggerFactory.getLogger("org.flywaydb");
+ Class> levelClass = Class.forName("ch.qos.logback.classic.Level");
+ Object warnLevel = levelClass.getField("WARN").get(null);
+ flywayLogger.getClass().getMethod("setLevel", levelClass).invoke(flywayLogger, warnLevel);
+ } catch (ReflectiveOperationException | LinkageError ignored) {
+ // WHY:日志实现不是 logback 时不影响迁移验收;测试自身仍只输出 masked URL。
+ }
+ }
+
private static int successfulMigrationCount(String url, String user, String password) throws SQLException {
try (Connection connection = DriverManager.getConnection(url, user, password);
PreparedStatement statement = connection.prepareStatement(
@@ -261,6 +296,32 @@ class P1rAiFlywayMigrationIT {
}
}
+ private static void assertNoCredentialQuery(String url) {
+ int queryStart = url.indexOf('?');
+ if (queryStart < 0) {
+ return;
+ }
+ String query = url.substring(queryStart + 1);
+ for (String parameter : query.split("&")) {
+ String key = parameter;
+ int equalsStart = key.indexOf('=');
+ if (equalsStart >= 0) {
+ key = key.substring(0, equalsStart);
+ }
+ assertFalse(isCredentialQueryKey(key),
+ "p1r.flyway.url 不能携带凭据 query 参数;请通过用户名属性和密码环境变量传入");
+ }
+ }
+
+ private static boolean isCredentialQueryKey(String rawKey) {
+ // WHY:URL 可能会被 Surefire 或日志记录,凭据只能走单独参数或环境变量,不能藏在 JDBC query 中。
+ String key = rawKey.trim().toLowerCase(Locale.ROOT).replace('-', '_');
+ return CREDENTIAL_QUERY_KEYS.contains(key)
+ || key.endsWith("_token")
+ || key.endsWith("_secret")
+ || key.endsWith("_password");
+ }
+
private static String maskedUrl(String url) {
int databaseStart = url.lastIndexOf('/');
if (databaseStart < 0) {
@@ -269,7 +330,7 @@ class P1rAiFlywayMigrationIT {
int queryStart = url.indexOf('?', databaseStart);
String prefix = url.substring(0, databaseStart + 1);
String database = queryStart < 0 ? url.substring(databaseStart + 1) : url.substring(databaseStart + 1, queryStart);
- String suffix = queryStart < 0 ? "" : url.substring(queryStart);
+ String suffix = queryStart < 0 ? "" : "?";
return prefix.replaceAll("//([^:/]+)", "//") + database + suffix;
}