package com.battery.business.runtime; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; import java.util.UUID; import org.apache.ibatis.datasource.unpooled.UnpooledDataSource; import org.junit.jupiter.api.Test; import org.springframework.core.io.ClassPathResource; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.datasource.init.ScriptUtils; import com.battery.device.api.model.AdapterCapabilities; import com.battery.device.api.registry.AdapterInstanceConfig; import com.battery.device.api.registry.AdapterRegistryException; import com.battery.device.api.registry.DeviceAdapterRegistry; /** * 适配器实例按存储形态放宽与来源形态配置列的迁移契约: * 迁移把 {@code source_config} 加在能力列之后,运行库上该列确实存在且可空; * 类型白名单放宽到直连厂商库,未知类型仍然拒绝。 */ class VendorSourceInstanceMigrationContractTest { @Test void migrationAddsNullableSourceConfigColumnWithoutTouchingExistingRows() throws Exception { String path = "db/migration-final/V22__vendor_source_instance_config.sql"; String migration = resource(path); assertTrue(migration.contains("ALTER TABLE t_device_adapter ADD COLUMN source_config JSON NULL")); assertTrue(migration.contains("AFTER capabilities")); // 迁移只加列:不改既有列、不写入任何数据,更不写入凭据。 assertFalse(migration.matches("(?s).*\\b(INSERT|UPDATE|DELETE|DROP)\\b.*")); assertFalse(migration.toLowerCase().contains("password")); var source = new UnpooledDataSource("org.h2.Driver", "jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE", "sa", ""); try (var connection = source.getConnection()) { JdbcTemplate jdbc = new JdbcTemplate(source); jdbc.execute("create table t_device_adapter(adapter_id bigint auto_increment primary key," + "adapter_code varchar(64), capabilities json)"); ScriptUtils.executeSqlScript(connection, new ClassPathResource(path)); // 来源形态配置只承载非敏感项;连接口令由环境变量提供,不入库。 jdbc.update("insert into t_device_adapter(adapter_code, capabilities, source_config) values(?,?,?)", "neware76", "{\"control\":false}", "{\"database\":\"bts63\",\"user\":\"visitor\"}"); assertEquals(1, jdbc.queryForObject( "select count(*) from t_device_adapter where source_config is not null", Integer.class)); // 没有来源形态配置的行(列可空)仍允许存在。 jdbc.update("insert into t_device_adapter(adapter_code, capabilities) values(?,?)", "legacy-row", "{\"control\":false}"); assertEquals(1, jdbc.queryForObject( "select count(*) from t_device_adapter where source_config is null", Integer.class)); } } @Test void sourceConfigCarriesOnlyNonSensitiveOptionsAndUnknownTypesStayRejected() { AdapterInstanceConfig config = new AdapterInstanceConfig("neware76", "NEWARE_MYSQL", "26.1.2.3", 3306, 60000, "Asia/Hong_Kong", true, AdapterCapabilities.observeOnly(), Map.of("database", "bts63", "user", "visitor", "passwordEnv", "BATTERY_VENDOR_DB_PASSWORD")); assertEquals("bts63", config.sourceOption("database")); assertTrue(config.capabilities().discovery()); assertFalse(config.capabilities().control()); try (DeviceAdapterRegistry registry = new DeviceAdapterRegistry(List.of())) { // 放宽不等于接受任何类型:未注册的存储形态仍然明确失败。 assertThrows(AdapterRegistryException.class, () -> registry.synchronize(new AdapterInstanceConfig("mystery", "SOME_OTHER_VENDOR", "127.0.0.1", 3306, 60000, "Asia/Hong_Kong", true, AdapterCapabilities.observeOnly(), Map.of()))); } } private static String resource(String path) { try (InputStream stream = VendorSourceInstanceMigrationContractTest.class.getClassLoader() .getResourceAsStream(path)) { if (stream == null) throw new IllegalStateException("缺少迁移文件:" + path); return new String(stream.readAllBytes(), StandardCharsets.UTF_8); } catch (java.io.IOException exception) { throw new IllegalStateException("无法读取迁移文件:" + path, exception); } } }