yeg
6 天以前 e38aca8134570513435c003c25dc05f1ae51c9c9
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
package com.battery.device.neware;
 
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
 
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
import java.util.Set;
import javax.sql.DataSource;
import org.junit.jupiter.api.Test;
import com.battery.device.api.model.AdapterCapabilities;
import com.battery.device.api.model.ChannelIdentity;
import com.battery.device.api.registry.AdapterInstanceConfig;
 
/**
 * 只读防线测试:非查询语句在到达驱动之前被拒绝,且该数据源在其生命周期内从未发出过非查询语句。
 */
class ReadOnlyVendorDataSourceTest
{
    private static final String ADAPTER_CODE = "neware76";
    private static final ChannelIdentity CHANNEL = new ChannelIdentity(ADAPTER_CODE, 21, 36, 1, 2);
 
    private ReadOnlyVendorDataSource dataSourceOver(FakeVendorDatabase database)
    {
        return new ReadOnlyVendorDataSource(database.dataSource(), ADAPTER_CODE);
    }
 
    @Test
    void queryStatementsAreForwardedAndRecorded() throws Exception
    {
        ReadOnlyVendorDataSource dataSource = new ReadOnlyVendorDataSource(new FakeVendorDatabase().dataSource(),
                ADAPTER_CODE);
        try (Connection connection = dataSource.getConnection();
                PreparedStatement statement = connection.prepareStatement("select 1 from channel"))
        {
            statement.executeQuery();
        }
        try (Connection connection = dataSource.getConnection();
                Statement statement = connection.createStatement())
        {
            statement.executeQuery("show tables");
            statement.executeQuery("describe `channel`");
        }
        assertTrue(dataSource.audit().rejectedStatements().isEmpty());
        assertEquals(3, dataSource.audit().forwardedStatements().size());
    }
 
    @Test
    void nonQueryStatementsAreRejectedBeforeTheyReachTheDriver() throws Exception
    {
        FakeVendorDatabase database = new FakeVendorDatabase();
        ReadOnlyVendorDataSource dataSource = dataSourceOver(database);
        List<String> nonQueries = List.of("update channel set chl_status = 1",
                "insert into channel(dev_uid) values(210036)", "delete from channel",
                "drop table data_210036_2026_09_18", "create index idx on channel(chl_status)",
                "truncate table channel", "set global read_only = 0", "grant select on *.* to x",
                "alter table channel add column x int", "call some_procedure()");
        for (String sql : nonQueries)
        {
            assertThrows(NonQueryStatementException.class, () -> {
                try (Connection connection = dataSource.getConnection();
                        PreparedStatement statement = connection.prepareStatement(sql))
                {
                    statement.execute();
                }
            }, "非查询语句必须被拒绝:" + sql);
        }
        assertEquals(nonQueries.size(), dataSource.audit().rejectedStatements().size());
        assertTrue(database.statements().isEmpty(), "被拒绝的语句不得到达驱动");
        // 语句级执行同样被拦下,并且没有任何非查询语句到达驱动。
        assertThrows(NonQueryStatementException.class, () -> {
            try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement())
            {
                statement.executeUpdate("update channel set chl_status = 1");
            }
        });
        assertThrows(NonQueryStatementException.class,
                () -> dataSource.getConnection().prepareCall("{call anything()}"));
        assertTrue(database.statements().isEmpty(), "被拒绝的语句不得到达驱动");
        assertEquals(nonQueries.size() + 1, dataSource.audit().rejectedStatements().size());
    }
 
    /**
     * 这条断言对应验收要求:整个适配器生命周期内,该数据源没有发出过任何非查询语句。
     */
    @Test
    void adapterLifecycleNeverSendsANonQueryStatement()
    {
        FakeVendorDatabase database = new FakeVendorDatabase()
                .registeredChannel(210036L, 1, 2)
                .test(new FakeVendorDatabase.TestRow(210036L, 1, 2, 440L, "2026-09-18 09:00:00",
                        "data_210036_2026_09_18", null))
                .sample(new FakeVendorDatabase.SampleRow("data_210036_2026_09_18", 440L, 1, 2, 7435L, 3, 12, "2",
                        300000L, 13733, -11889, 0, 0, 50350024, 1103331, "2026-09-28 09:54:56"));
        ReadOnlyVendorDataSource guarded = dataSourceOver(database);
        AdapterInstanceConfig config = new AdapterInstanceConfig(ADAPTER_CODE, NewareMysqlAdapter.ADAPTER_TYPE,
                "26.1.2.3", 3306, 60000, "Asia/Hong_Kong", true, AdapterCapabilities.observeOnly(),
                Map.of("database", "bts63"));
        NewareMysqlAdapter adapter = new NewareMysqlAdapter(config, guarded, () -> { },
                Clock.fixed(Instant.parse("2026-09-28T02:00:00Z"), ZoneOffset.UTC), 0);
 
        adapter.discoverChannels();
        adapter.inquire(Set.of(CHANNEL));
        adapter.latestCabinet(CHANNEL);
        adapter.currentWatermark(CHANNEL);
        adapter.samplesSince(CHANNEL,
                new com.battery.device.api.TestDataObservationAdapter.SampleWatermark(
                        Instant.parse("2026-09-28T01:00:00Z"), 1L));
 
        assertTrue(guarded.audit().rejectedStatements().isEmpty(),
                "适配器不应尝试任何非查询语句:" + guarded.audit().rejectedStatements());
        assertTrue(!database.statements().isEmpty(), "适配器应当实际读取来源");
        for (String statement : database.statements())
        {
            assertTrue(ReadOnlyVendorDataSource.isQuery(statement), "到达驱动的语句必须是查询:" + statement);
        }
    }
 
    @Test
    void statementClassificationKeepsQueriesAndRejectsEverythingElse()
    {
        assertTrue(ReadOnlyVendorDataSource.isQuery("select dev_uid from channel"));
        assertTrue(ReadOnlyVendorDataSource.isQuery("\n  SELECT 1"));
        assertTrue(ReadOnlyVendorDataSource.isQuery("/* probe */ show grants"));
        assertTrue(ReadOnlyVendorDataSource.isQuery("-- probe\n describe channel"));
        assertTrue(ReadOnlyVendorDataSource.isQuery("desc `channel`"));
        assertTrue(!ReadOnlyVendorDataSource.isQuery("update channel set chl_status = 0"));
        assertTrue(!ReadOnlyVendorDataSource.isQuery(""));
        // 以 select 开头但紧跟字母的标识符不是查询语句。
        assertTrue(!ReadOnlyVendorDataSource.isQuery("selection_helper()"));
    }
 
    @Test
    void rejectedStatementsNeverReachTheDriverEvenWhenTheApplicationKeepsTrying()
    {
        // 连续尝试写操作后,来源侧一条语句都没收到,说明拦截发生在驱动之前。
        FakeVendorDatabase database = new FakeVendorDatabase();
        ReadOnlyVendorDataSource dataSource = dataSourceOver(database);
        for (String sql : List.of("update channel set chl_status = 1", "delete from channel",
                "insert into channel(dev_uid) values(210036)"))
        {
            try
            {
                dataSource.getConnection().prepareStatement(sql);
            }
            catch (Exception ignored)
            {
                // 每条都被白名单挡下。
            }
        }
        assertTrue(database.statements().isEmpty());
        assertEquals(3, dataSource.audit().rejectedStatements().size());
    }
 
    /** 加锁读以 select 开头却是真的会阻塞厂商写入,所以必须在到达驱动之前被单独拒绝。 */
    @Test
    void lockingReadsAreRejectedBeforeTheyReachTheDriver()
    {
        FakeVendorDatabase database = new FakeVendorDatabase();
        ReadOnlyVendorDataSource dataSource = dataSourceOver(database);
        for (String sql : List.of("select dev_uid from channel for update",
                "select dev_uid from channel FOR SHARE",
                "select dev_uid from channel lock in share mode",
                "select dev_uid from channel with (updlock)",
                "/* probe */ select dev_uid from channel with (tablock, holdlock)"))
        {
            assertTrue(ReadOnlyVendorDataSource.isQuery(sql), "加锁读仍然以查询开头:" + sql);
            assertTrue(ReadOnlyVendorDataSource.containsLockingClause(sql), "应当识别为加锁读:" + sql);
            assertThrows(NonQueryStatementException.class,
                    () -> dataSource.getConnection().prepareStatement(sql), sql);
        }
        assertTrue(database.statements().isEmpty(), "加锁读不得到达驱动");
        assertTrue(!ReadOnlyVendorDataSource.containsLockingClause("select dev_uid from channel"));
        assertTrue(!ReadOnlyVendorDataSource.containsLockingClause(""));
    }
 
    /** 连接超时与读取超时都必须由我们显式给出,且不得写入只读事务参数(5.5 不支持)。 */
    @Test
    void vendorJdbcUrlCarriesBothTimeoutsAndNoReadOnlySessionVariable()
    {
        String url = NewareMysqlAdapterFactory.jdbcUrl("26.1.2.3", 3306, "bts63");
 
        assertTrue(url.contains("connectTimeout="), url);
        assertTrue(url.contains("socketTimeout="), url);
        assertTrue(!url.contains("transaction_read_only"), url);
    }
}