Commit 8618426f1b75e7d7db609ecc4c392726587302fe
1 parent
86137492
fix(iot): 校验数据库数据目的表名
Showing
5 changed files
with
153 additions
and
0 deletions
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/data/sink/IotDataSinkSaveReqVO.java
| ... | ... | @@ -5,6 +5,7 @@ import cn.iocoder.yudao.framework.common.validation.InEnum; |
| 5 | 5 | import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotAbstractDataSinkConfig; |
| 6 | 6 | import cn.iocoder.yudao.module.iot.enums.rule.IotDataSinkTypeEnum; |
| 7 | 7 | import io.swagger.v3.oas.annotations.media.Schema; |
| 8 | +import jakarta.validation.Valid; | |
| 8 | 9 | import jakarta.validation.constraints.NotEmpty; |
| 9 | 10 | import jakarta.validation.constraints.NotNull; |
| 10 | 11 | import lombok.Data; |
| ... | ... | @@ -35,6 +36,7 @@ public class IotDataSinkSaveReqVO { |
| 35 | 36 | |
| 36 | 37 | @Schema(description = "数据目的配置") |
| 37 | 38 | @NotNull(message = "数据目的配置不能为空") |
| 39 | + @Valid | |
| 38 | 40 | private IotAbstractDataSinkConfig config; |
| 39 | 41 | |
| 40 | 42 | } |
| 41 | 43 | \ No newline at end of file | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/config/IotDataSinkDatabaseConfig.java
| 1 | 1 | package cn.iocoder.yudao.module.iot.dal.dataobject.rule.config; |
| 2 | 2 | |
| 3 | +import jakarta.validation.constraints.NotEmpty; | |
| 4 | +import jakarta.validation.constraints.Pattern; | |
| 3 | 5 | import lombok.Data; |
| 4 | 6 | |
| 5 | 7 | /** |
| ... | ... | @@ -15,6 +17,13 @@ import lombok.Data; |
| 15 | 17 | public class IotDataSinkDatabaseConfig extends IotAbstractDataSinkConfig { |
| 16 | 18 | |
| 17 | 19 | /** |
| 20 | + * 目标表名的格式:只允许字母、数字、下划线,且不以数字开头;支持可选的 schema.table 格式 | |
| 21 | + * | |
| 22 | + * 目的:表名会拼接到 INSERT 语句中,保存和执行时都必须校验 | |
| 23 | + */ | |
| 24 | + public static final String TABLE_NAME_REGEX = "^[A-Za-z_][A-Za-z0-9_]{0,127}(\\.[A-Za-z_][A-Za-z0-9_]{0,127})?$"; | |
| 25 | + | |
| 26 | + /** | |
| 18 | 27 | * JDBC 连接地址 |
| 19 | 28 | * |
| 20 | 29 | * 例如:jdbc:mysql://localhost:3306/iot_data |
| ... | ... | @@ -37,6 +46,8 @@ public class IotDataSinkDatabaseConfig extends IotAbstractDataSinkConfig { |
| 37 | 46 | * |
| 38 | 47 | * 设备消息将以固定结构写入该表 |
| 39 | 48 | */ |
| 49 | + @NotEmpty(message = "目标表名不能为空") | |
| 50 | + @Pattern(regexp = TABLE_NAME_REGEX, message = "目标表名格式不正确,只允许字母、数字、下划线,或 schema.table 格式") | |
| 40 | 51 | private String tableName; |
| 41 | 52 | |
| 42 | 53 | } | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotDatabaseDataRuleAction.java
| 1 | 1 | package cn.iocoder.yudao.module.iot.service.rule.data.action; |
| 2 | 2 | |
| 3 | +import cn.hutool.core.util.ReUtil; | |
| 3 | 4 | import cn.hutool.core.util.StrUtil; |
| 4 | 5 | import cn.iocoder.yudao.framework.common.util.json.JsonUtils; |
| 5 | 6 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| ... | ... | @@ -33,6 +34,10 @@ public class IotDatabaseDataRuleAction extends |
| 33 | 34 | @Override |
| 34 | 35 | public void execute(IotDeviceMessage message, IotDataSinkDatabaseConfig config) throws Exception { |
| 35 | 36 | try { |
| 37 | + // 0. 校验表名:执行时再次校验,避免历史保存的非法表名拼接到 SQL 中 | |
| 38 | + if (!ReUtil.isMatch(IotDataSinkDatabaseConfig.TABLE_NAME_REGEX, config.getTableName())) { | |
| 39 | + throw new IllegalArgumentException(StrUtil.format("数据库目的的表名({}) 不合法", config.getTableName())); | |
| 40 | + } | |
| 36 | 41 | // 1. 获取或创建 JdbcTemplate |
| 37 | 42 | JdbcTemplate jdbcTemplate = getProducer(config); |
| 38 | 43 | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/data/sink/IotDataSinkSaveReqVOTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.controller.admin.rule.vo.data.sink; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; | |
| 4 | +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotAbstractDataSinkConfig; | |
| 5 | +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkDatabaseConfig; | |
| 6 | +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkHttpConfig; | |
| 7 | +import cn.iocoder.yudao.module.iot.enums.rule.IotDataSinkTypeEnum; | |
| 8 | +import jakarta.validation.ConstraintViolation; | |
| 9 | +import jakarta.validation.Validation; | |
| 10 | +import jakarta.validation.Validator; | |
| 11 | +import org.junit.jupiter.api.Test; | |
| 12 | + | |
| 13 | +import java.util.Arrays; | |
| 14 | +import java.util.Set; | |
| 15 | + | |
| 16 | +import static org.junit.jupiter.api.Assertions.assertEquals; | |
| 17 | +import static org.junit.jupiter.api.Assertions.assertTrue; | |
| 18 | + | |
| 19 | +/** | |
| 20 | + * {@link IotDataSinkSaveReqVO} 的参数校验单元测试 | |
| 21 | + * | |
| 22 | + * @author 芋道源码 | |
| 23 | + */ | |
| 24 | +public class IotDataSinkSaveReqVOTest { | |
| 25 | + | |
| 26 | + private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); | |
| 27 | + | |
| 28 | + @Test | |
| 29 | + public void testValidate_databaseTableNameValid() { | |
| 30 | + for (String tableName : Arrays.asList("iot_device_message", "iot_data.device_message")) { | |
| 31 | + // 准备参数 | |
| 32 | + IotDataSinkSaveReqVO reqVO = buildReqVO(IotDataSinkTypeEnum.DATABASE.getType(), buildDatabaseConfig(tableName)); | |
| 33 | + | |
| 34 | + // 调用,并断言 | |
| 35 | + assertTrue(validator.validate(reqVO).isEmpty(), tableName); | |
| 36 | + } | |
| 37 | + } | |
| 38 | + | |
| 39 | + @Test | |
| 40 | + public void testValidate_databaseTableNameInvalid() { | |
| 41 | + for (String tableName : Arrays.asList(null, "", "1message", "message; DROP TABLE system_users", | |
| 42 | + "message -- ", "message/**/", "`message`", "a.b.c", "message(id)")) { | |
| 43 | + // 准备参数 | |
| 44 | + IotDataSinkSaveReqVO reqVO = buildReqVO(IotDataSinkTypeEnum.DATABASE.getType(), buildDatabaseConfig(tableName)); | |
| 45 | + | |
| 46 | + // 调用 | |
| 47 | + Set<ConstraintViolation<IotDataSinkSaveReqVO>> violations = validator.validate(reqVO); | |
| 48 | + // 断言:通过 @Valid 级联校验到配置的表名 | |
| 49 | + assertTrue(!violations.isEmpty(), String.valueOf(tableName)); | |
| 50 | + violations.forEach(violation -> assertEquals("config.tableName", violation.getPropertyPath().toString())); | |
| 51 | + } | |
| 52 | + } | |
| 53 | + | |
| 54 | + @Test | |
| 55 | + public void testValidate_otherConfigNotAffected() { | |
| 56 | + // 准备参数:其它类型的配置,没有新增的校验约束 | |
| 57 | + IotDataSinkHttpConfig config = new IotDataSinkHttpConfig(); | |
| 58 | + config.setUrl("https://www.iocoder.cn"); | |
| 59 | + IotDataSinkSaveReqVO reqVO = buildReqVO(IotDataSinkTypeEnum.HTTP.getType(), config); | |
| 60 | + | |
| 61 | + // 调用,并断言 | |
| 62 | + assertTrue(validator.validate(reqVO).isEmpty()); | |
| 63 | + } | |
| 64 | + | |
| 65 | + private static IotDataSinkSaveReqVO buildReqVO(Integer type, IotAbstractDataSinkConfig config) { | |
| 66 | + IotDataSinkSaveReqVO reqVO = new IotDataSinkSaveReqVO(); | |
| 67 | + reqVO.setName("数据目的"); | |
| 68 | + reqVO.setStatus(CommonStatusEnum.ENABLE.getStatus()); | |
| 69 | + reqVO.setType(type); | |
| 70 | + reqVO.setConfig(config); | |
| 71 | + return reqVO; | |
| 72 | + } | |
| 73 | + | |
| 74 | + private static IotDataSinkDatabaseConfig buildDatabaseConfig(String tableName) { | |
| 75 | + IotDataSinkDatabaseConfig config = new IotDataSinkDatabaseConfig(); | |
| 76 | + config.setJdbcUrl("jdbc:mysql://127.0.0.1:3306/iot_data"); | |
| 77 | + config.setTableName(tableName); | |
| 78 | + return config; | |
| 79 | + } | |
| 80 | + | |
| 81 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotDatabaseDataRuleActionTest.java
| 1 | 1 | package cn.iocoder.yudao.module.iot.service.rule.data.action; |
| 2 | 2 | |
| 3 | +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | |
| 4 | +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkDatabaseConfig; | |
| 3 | 5 | import cn.iocoder.yudao.module.iot.enums.rule.IotDataSinkTypeEnum; |
| 4 | 6 | import com.zaxxer.hikari.HikariDataSource; |
| 5 | 7 | import org.junit.jupiter.api.BeforeEach; |
| ... | ... | @@ -9,9 +11,12 @@ import org.mockito.MockitoAnnotations; |
| 9 | 11 | import org.springframework.jdbc.core.JdbcTemplate; |
| 10 | 12 | |
| 11 | 13 | import javax.sql.DataSource; |
| 14 | +import java.time.LocalDateTime; | |
| 15 | +import java.util.Arrays; | |
| 12 | 16 | |
| 13 | 17 | import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; |
| 14 | 18 | import static org.junit.jupiter.api.Assertions.assertEquals; |
| 19 | +import static org.junit.jupiter.api.Assertions.assertThrows; | |
| 15 | 20 | import static org.mockito.Mockito.*; |
| 16 | 21 | |
| 17 | 22 | /** |
| ... | ... | @@ -39,6 +44,41 @@ class IotDatabaseDataRuleActionTest { |
| 39 | 44 | } |
| 40 | 45 | |
| 41 | 46 | @Test |
| 47 | + public void testExecute_tableNameValid() throws Exception { | |
| 48 | + // 准备参数:普通表名,以及 schema.table 格式 | |
| 49 | + for (String tableName : Arrays.asList("iot_device_message", "_message01", "iot_data.device_message")) { | |
| 50 | + IotDataSinkDatabaseConfig config = buildConfig(tableName); | |
| 51 | + IotDatabaseDataRuleAction action = spy(databaseDataRuleAction); | |
| 52 | + doReturn(jdbcTemplate).when(action).getProducer(same(config)); | |
| 53 | + | |
| 54 | + // 调用 | |
| 55 | + action.execute(buildMessage(), config); | |
| 56 | + | |
| 57 | + // 断言:表名原样拼接,值使用参数绑定 | |
| 58 | + verify(jdbcTemplate).update(eq("INSERT INTO " + tableName | |
| 59 | + + " (id, device_id, tenant_id, method, report_time, data, create_time) VALUES (?, ?, ?, ?, ?, ?, NOW())"), | |
| 60 | + any(), any(), any(), any(), any(), any()); | |
| 61 | + } | |
| 62 | + } | |
| 63 | + | |
| 64 | + @Test | |
| 65 | + public void testExecute_tableNameInvalid() throws Exception { | |
| 66 | + // 准备参数:模拟历史已保存的非法表名,执行时也必须拦截 | |
| 67 | + for (String tableName : Arrays.asList(null, "", "1message", "device message", | |
| 68 | + "message; DROP TABLE system_users", "message -- ", "message/**/", "`message`", "\"message\"", | |
| 69 | + "message(id)", "a.b.c", ".message", "message.", "表名")) { | |
| 70 | + IotDataSinkDatabaseConfig config = buildConfig(tableName); | |
| 71 | + IotDatabaseDataRuleAction action = spy(databaseDataRuleAction); | |
| 72 | + | |
| 73 | + // 调用,并断言异常 | |
| 74 | + assertThrows(IllegalArgumentException.class, () -> action.execute(buildMessage(), config)); | |
| 75 | + // 断言:校验在获取连接之前,不会创建连接池、执行 SQL | |
| 76 | + verify(action, never()).getProducer(any()); | |
| 77 | + } | |
| 78 | + verifyNoInteractions(jdbcTemplate); | |
| 79 | + } | |
| 80 | + | |
| 81 | + @Test | |
| 42 | 82 | public void testCloseProducer_whenHikari() throws Exception { |
| 43 | 83 | // 准备:底层是 HikariDataSource |
| 44 | 84 | HikariDataSource hikari = mock(HikariDataSource.class); |
| ... | ... | @@ -62,4 +102,18 @@ class IotDatabaseDataRuleActionTest { |
| 62 | 102 | verifyNoInteractions(other); |
| 63 | 103 | } |
| 64 | 104 | |
| 105 | + private static IotDataSinkDatabaseConfig buildConfig(String tableName) { | |
| 106 | + IotDataSinkDatabaseConfig config = new IotDataSinkDatabaseConfig(); | |
| 107 | + config.setJdbcUrl("jdbc:mysql://127.0.0.1:3306/iot_data"); | |
| 108 | + config.setUsername("root"); | |
| 109 | + config.setPassword("123456"); | |
| 110 | + config.setTableName(tableName); | |
| 111 | + return config; | |
| 112 | + } | |
| 113 | + | |
| 114 | + private static IotDeviceMessage buildMessage() { | |
| 115 | + return new IotDeviceMessage().setId("message_01").setDeviceId(1L).setTenantId(1L) | |
| 116 | + .setMethod("thing.property.post").setReportTime(LocalDateTime.now()); | |
| 117 | + } | |
| 118 | + | |
| 65 | 119 | } | ... | ... |