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; }