test(p1r): 加固 AI Flyway 迁移验收凭据边界
将 AI 真实 Flyway 验收的数据库密码输入收敛到环境变量,拒绝 JDBC URL query 凭据,并遮蔽输出 query,避免迁移 gate 泄露敏感信息。
This commit is contained in:
parent
4f38bf0da1
commit
751b61cce9
@ -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);
|
||||
// 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 ? "" : "?<query-redacted>";
|
||||
return prefix.replaceAll("//([^:/]+)", "//<host>") + database + suffix;
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user