test(p1r): 加固 AI Flyway 迁移验收凭据边界

将 AI 真实 Flyway 验收的数据库密码输入收敛到环境变量,拒绝 JDBC URL query 凭据,并遮蔽输出 query,避免迁移 gate 泄露敏感信息。
This commit is contained in:
zizi 2026-06-02 19:33:17 +08:00
parent 4f38bf0da1
commit 751b61cce9

View File

@ -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 迁移验收
*
* <p>本测试只接受显式传入的 p1r.flyway.* 参数避免误连默认开发库
* <p>本测试只接受显式传入的 Flyway URL/用户/位置参数与环境变量密码避免误连默认开发库
* 运行前会清理当前隔离测试库的 public schema再执行 sql/muse V1-V13 全量迁移</p>
*/
class P1rAiFlywayMigrationIT {
@ -53,17 +56,35 @@ class P1rAiFlywayMigrationIT {
"idx_muse_ai_runtime_payload_active",
"idx_muse_ai_runtime_payload_cleanup"
);
private static final Set<String> 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);
// WHYTask 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) {
// WHYURL 可能会被 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 ? "" : "?<query-redacted>";
return prefix.replaceAll("//([^:/]+)", "//<host>") + database + suffix;
}