dsh-api-runner 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/test/db.mjs ADDED
@@ -0,0 +1,512 @@
1
+ /**
2
+ * 数据库功能的集成测试(node test/db.mjs)。
3
+ *
4
+ * 用 Node 内置的 node:sqlite 做真实数据库,不需要装任何原生模块:
5
+ * 建表、插入、参数化查询、元数据、只读模式、多语句拦截、结果截断、
6
+ * 密码加密存储与脱敏,全部在这里断言。
7
+ *
8
+ * 不依赖 cordis,纯 node 可跑。
9
+ */
10
+ import assert from 'node:assert/strict';
11
+ import fs from 'node:fs';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+ import { fileURLToPath, pathToFileURL } from 'node:url';
15
+
16
+ const here = path.dirname(fileURLToPath(import.meta.url));
17
+ const root = path.resolve(here, '..');
18
+ const load = (rel) => import(pathToFileURL(path.join(root, rel)).href);
19
+
20
+ const { DbManager, splitStatements, isReadOnlySql, encryptSecret, decryptSecret } = await load('src/db.js');
21
+ const { ApiRunnerService } = await load('src/ops.js');
22
+
23
+ let passed = 0;
24
+ function ok(name, fn) {
25
+ return Promise.resolve()
26
+ .then(fn)
27
+ .then(() => {
28
+ passed += 1;
29
+ console.log(` ✓ ${name}`);
30
+ })
31
+ .catch((error) => {
32
+ console.error(` ✗ ${name}\n ${error?.stack || error}`);
33
+ process.exitCode = 1;
34
+ });
35
+ }
36
+
37
+ // ---------- 临时工作区 ----------
38
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'api-runner-db-'));
39
+ const homeDir = path.join(tmp, 'home');
40
+ fs.mkdirSync(homeDir, { recursive: true });
41
+ const dbFile = path.join(tmp, 'test.db');
42
+
43
+ const manager = new DbManager({ homeDir });
44
+
45
+ /**
46
+ * 授权辅助:默认拒绝是核心安全设计,所以几乎每个用例都要先显式授权。
47
+ * 测试里统一用 'default' 项目 + readonly,写操作用 readwrite。
48
+ */
49
+ const PROJECT = 'default';
50
+ function grant(connectionId, level = 'readonly') {
51
+ const r = manager.setAccess(PROJECT, connectionId, level);
52
+ assert.equal(r.ok, true, JSON.stringify(r));
53
+ return connectionId;
54
+ }
55
+
56
+ console.log('SQL 解析:');
57
+ await ok('单条语句不误判', () => {
58
+ assert.deepEqual(splitStatements('SELECT 1'), ['SELECT 1']);
59
+ assert.deepEqual(splitStatements('SELECT 1;'), ['SELECT 1'], '末尾分号不算多语句');
60
+ });
61
+
62
+ await ok('字符串字面量里的分号不算分隔符', () => {
63
+ const sql = "SELECT * FROM t WHERE note = 'a;b'";
64
+ assert.deepEqual(splitStatements(sql), [sql], '引号内的分号不应拆开');
65
+ });
66
+
67
+ await ok('真正的多语句会被识别', () => {
68
+ assert.equal(splitStatements('SELECT 1; SELECT 2').length, 2);
69
+ assert.equal(splitStatements("SELECT 1; DELETE FROM t").length, 2);
70
+ });
71
+
72
+ await ok('注释里的分号不算分隔符', () => {
73
+ assert.deepEqual(splitStatements('-- a; b\nSELECT 1'), ['-- a; b\nSELECT 1']);
74
+ assert.deepEqual(splitStatements('/* x; y */ SELECT 1'), ['/* x; y */ SELECT 1']);
75
+ });
76
+
77
+ await ok('只读判定:SELECT/WITH/PRAGMA 算读,写操作不算', () => {
78
+ assert.equal(isReadOnlySql('SELECT * FROM t'), true);
79
+ assert.equal(isReadOnlySql(' select 1'), true);
80
+ assert.equal(isReadOnlySql('WITH x AS (SELECT 1) SELECT * FROM x'), true);
81
+ assert.equal(isReadOnlySql('PRAGMA table_info(t)'), true);
82
+ assert.equal(isReadOnlySql('INSERT INTO t VALUES (1)'), false);
83
+ assert.equal(isReadOnlySql('UPDATE t SET a = 1'), false);
84
+ assert.equal(isReadOnlySql('DELETE FROM t'), false);
85
+ assert.equal(isReadOnlySql('DROP TABLE t'), false);
86
+ assert.equal(isReadOnlySql('-- 注释\nSELECT 1'), true, '前置行注释不影响判定');
87
+ });
88
+
89
+ console.log('密码加密:');
90
+ await ok('加密后不是明文,且能解回原值', () => {
91
+ const cipher = encryptSecret('p@ssw0rd');
92
+ assert.notEqual(cipher, 'p@ssw0rd');
93
+ assert.ok(cipher.startsWith('v1:'));
94
+ assert.equal(decryptSecret(cipher), 'p@ssw0rd');
95
+ });
96
+
97
+ await ok('空密码保持空串', () => {
98
+ assert.equal(encryptSecret(''), '');
99
+ assert.equal(decryptSecret(''), '');
100
+ });
101
+
102
+ await ok('损坏的密文不抛异常,返回空串', () => {
103
+ assert.equal(decryptSecret('v1:not-a-valid-base64!!!'), '');
104
+ });
105
+
106
+ console.log('连接管理:');
107
+ let connId = '';
108
+ await ok('保存 SQLite 连接,密码不落明文', () => {
109
+ const saved = manager.saveConnection({
110
+ name: '本地测试库',
111
+ type: 'sqlite',
112
+ database: dbFile,
113
+ username: 'root',
114
+ password: 'secret-pw',
115
+ });
116
+ assert.equal(saved.ok, true, JSON.stringify(saved));
117
+ connId = saved.connection.id;
118
+ assert.ok(connId, '应生成连接 id');
119
+
120
+ // 返回给外部的视图不含明文密码
121
+ assert.equal(saved.connection.hasPassword, true);
122
+ assert.equal(JSON.stringify(saved.connection).includes('secret-pw'), false, '视图里不能出现明文密码');
123
+
124
+ // 落盘文件里也不能有明文
125
+ const raw = fs.readFileSync(path.join(homeDir, 'db-connections.json'), 'utf8');
126
+ assert.equal(raw.includes('secret-pw'), false, '落盘文件里不能有明文密码');
127
+ });
128
+
129
+ await ok('列表返回脱敏视图', () => {
130
+ const list = manager.listConnections();
131
+ assert.equal(list.length, 1);
132
+ assert.equal(list[0].name, '本地测试库');
133
+ assert.equal(JSON.stringify(list).includes('secret-pw'), false);
134
+ });
135
+
136
+ await ok('更新连接时不传密码则保留原密码', () => {
137
+ manager.saveConnection({ id: connId, name: '改了名字', type: 'sqlite', database: dbFile });
138
+ const full = manager._fullConfig(connId);
139
+ assert.equal(full.password, 'secret-pw', '编辑时不传密码不应清空');
140
+ assert.equal(manager.listConnections()[0].name, '改了名字');
141
+ });
142
+
143
+ await ok('缺少必填项时给出明确错误', () => {
144
+ const noName = manager.saveConnection({ type: 'sqlite', database: dbFile });
145
+ assert.equal(noName.ok, false);
146
+ assert.equal(noName.code, 'MISSING_NAME');
147
+
148
+ const noDb = manager.saveConnection({ name: 'x', type: 'sqlite' });
149
+ assert.equal(noDb.ok, false);
150
+ assert.equal(noDb.code, 'MISSING_DATABASE');
151
+
152
+ const noHost = manager.saveConnection({ name: 'y', type: 'mysql' });
153
+ assert.equal(noHost.ok, false);
154
+ assert.equal(noHost.code, 'MISSING_HOST');
155
+
156
+ const badType = manager.saveConnection({ name: 'z', type: 'mongodb' });
157
+ assert.equal(badType.ok, false);
158
+ assert.equal(badType.code, 'BAD_TYPE');
159
+ });
160
+
161
+ await ok('支持达梦(dm)类型,默认端口 5236', () => {
162
+ const saved = manager.saveConnection({
163
+ name: '达梦测试',
164
+ type: 'dm',
165
+ host: '127.0.0.1',
166
+ username: 'SYSDBA',
167
+ password: 'pw',
168
+ });
169
+ assert.equal(saved.ok, true, JSON.stringify(saved));
170
+ assert.equal(saved.connection.type, 'dm');
171
+ assert.equal(saved.connection.port, 5236, '达梦默认端口应是 5236');
172
+ manager.deleteConnection(saved.connection.id);
173
+ });
174
+
175
+ await ok('URL 模式:可以不填 host,回显打码', () => {
176
+ const saved = manager.saveConnection({
177
+ name: 'URL 模式',
178
+ type: 'dm',
179
+ connectString: 'dm://FUND:secret123@192.168.0.144:8185?loginEncrypt=false&schema=FTPDB',
180
+ });
181
+ assert.equal(saved.ok, true, JSON.stringify(saved));
182
+ assert.equal(saved.connection.urlMode, true);
183
+ assert.equal(saved.connection.host, '', 'URL 模式不应强制 host');
184
+ assert.equal(saved.connection.connectString.includes('secret123'), false, 'URL 回显不能含明文密码');
185
+ assert.ok(saved.connection.connectString.includes('***'), 'URL 回显应含掩码');
186
+ assert.ok(saved.connection.connectString.includes('192.168.0.144:8185'), '其余部分应保留');
187
+ manager.deleteConnection(saved.connection.id);
188
+ });
189
+
190
+ await ok('分字段模式仍要求 host,错误提示指引 URL 模式', () => {
191
+ const noHost = manager.saveConnection({ name: 'a', type: 'mysql', username: 'u' });
192
+ assert.equal(noHost.ok, false);
193
+ assert.equal(noHost.code, 'MISSING_HOST');
194
+ assert.match(noHost.message, /URL 模式/);
195
+ });
196
+
197
+ await ok('草稿试连不落盘,不污染连接列表', async () => {
198
+ const before = manager.listConnections().length;
199
+ const result = await manager.testConnection({ type: 'sqlite', database: dbFile });
200
+ assert.equal(result.ok, true, JSON.stringify(result));
201
+ assert.equal(manager.listConnections().length, before, '试连不应新增记录');
202
+ });
203
+
204
+ await ok('试连用独立临时 id,不会和已保存连接撞池别名', async () => {
205
+ // 先保存一条真实连接
206
+ const saved = manager.saveConnection({ name: '别名冲突测试', type: 'dm', host: '127.0.0.1', port: 59997, username: 'u', password: 'p' });
207
+ assert.equal(saved.ok, true, JSON.stringify(saved));
208
+
209
+ // 编辑时用该连接 id 试连:草稿 id 必须被换成 probe-*,否则 dmdb 会报「连接池别名已存在」
210
+ const r1 = await manager.testConnection({ id: saved.connection.id, type: 'dm', host: '127.0.0.1', port: 59997, username: 'u' }, { timeoutMs: 2000 });
211
+ assert.equal(r1.ok, false, '这个端口没人监听,应失败');
212
+ // 关键:错误不能是「别名已存在」这种配置级错误
213
+ assert.equal(/别名已存在/.test(String(r1.message)), false, `不应撞池别名:${r1.message}`);
214
+
215
+ // 连续试两次也不能撞
216
+ const r2 = await manager.testConnection({ id: saved.connection.id, type: 'dm', host: '127.0.0.1', port: 59997, username: 'u' }, { timeoutMs: 2000 });
217
+ assert.equal(/别名已存在/.test(String(r2.message)), false, `第二次仍不应撞池别名:${r2.message}`);
218
+
219
+ manager.deleteConnection(saved.connection.id);
220
+ });
221
+
222
+ await ok('试连有超时上限,不会让前端无限等待', async () => {
223
+ const started = Date.now();
224
+ // 指向一个不可达地址,超时设 2 秒
225
+ const result = await manager.testConnection(
226
+ { type: 'dm', host: '10.255.255.1', port: 5236, username: 'u', password: 'p' },
227
+ { timeoutMs: 2000 },
228
+ );
229
+ const elapsed = Date.now() - started;
230
+ assert.equal(result.ok, false);
231
+ assert.ok(elapsed < 8000, `试连应在超时附近返回,实际等了 ${elapsed}ms`);
232
+ assert.ok(['CONNECT_FAILED', 'PROBE_TIMEOUT', 'DB_ERROR'].includes(result.code), JSON.stringify(result));
233
+ });
234
+
235
+ await ok('草稿缺必填项时给出明确错误,不崩', async () => {
236
+ const result = await manager.testConnection({ type: 'sqlite' });
237
+ assert.equal(result.ok, false);
238
+ assert.equal(result.code, 'MISSING_DATABASE');
239
+ });
240
+
241
+ await ok('多个达梦连接有各自独立的池别名', () => {
242
+ const a = manager.saveConnection({ name: 'dm-a', type: 'dm', host: '127.0.0.1', port: 5236, username: 'u1', password: 'p' });
243
+ const b = manager.saveConnection({ name: 'dm-b', type: 'dm', host: '127.0.0.2', port: 5236, username: 'u2', password: 'p' });
244
+ assert.equal(a.ok, true, JSON.stringify(a));
245
+ assert.equal(b.ok, true, JSON.stringify(b));
246
+ assert.notEqual(a.connection.id, b.connection.id, '两条连接应有不同 id');
247
+ manager.deleteConnection(a.connection.id);
248
+ manager.deleteConnection(b.connection.id);
249
+ });
250
+
251
+ await ok('URL 里的密码不会出现在错误信息里', async () => {
252
+ const saved = manager.saveConnection({
253
+ name: '错误脱敏',
254
+ type: 'dm',
255
+ connectString: 'dm://u:verysecretpw@127.0.0.1:59998',
256
+ });
257
+ assert.equal(saved.ok, true);
258
+ const result = await manager.query(saved.connection.id, 'SELECT 1', [], { timeoutMs: 1500, projectKey: PROJECT });
259
+ if (result.ok === false) {
260
+ assert.equal(String(result.message).includes('verysecretpw'), false, '错误信息不应泄漏密码');
261
+ }
262
+ manager.deleteConnection(saved.connection.id);
263
+ });
264
+
265
+ await ok('DB_TYPES 包含 dm 与 oracle', async () => {
266
+ const { DB_TYPES } = await load('src/db.js');
267
+ assert.ok(DB_TYPES.includes('dm'), '应支持达梦');
268
+ assert.ok(DB_TYPES.includes('oracle'));
269
+ assert.ok(DB_TYPES.includes('sqlite'));
270
+ assert.ok(DB_TYPES.includes('mysql'));
271
+ assert.ok(DB_TYPES.includes('postgres'));
272
+ });
273
+
274
+ await ok('缺少 dmdb 驱动时给出可操作的提示,不崩', async () => {
275
+ const saved = manager.saveConnection({
276
+ name: '达梦无驱动',
277
+ type: 'dm',
278
+ host: '127.0.0.1',
279
+ port: 5236,
280
+ username: 'SYSDBA',
281
+ });
282
+ assert.equal(saved.ok, true);
283
+ // 先授权,才能走到「尝试连接」这一步(否则会先被授权层拦掉)
284
+ grant(saved.connection.id, 'readonly');
285
+ // 本机可能装了 dmdb 也可能没装;两种情况都必须是「结构化失败」而不是抛异常
286
+ const result = await manager.query(saved.connection.id, 'SELECT 1', [], { projectKey: PROJECT });
287
+ if (result.ok === false) {
288
+ assert.ok(['CONNECT_FAILED', 'DB_ERROR'].includes(result.code), JSON.stringify(result));
289
+ assert.equal(typeof result.message, 'string');
290
+ assert.ok(result.message.length > 0);
291
+ // 提示里不能出现明文密码
292
+ assert.equal(result.message.includes('pw'), false, '错误信息不应泄漏密码');
293
+ }
294
+ manager.deleteConnection(saved.connection.id);
295
+ });
296
+
297
+ console.log('连接与执行:');
298
+ await ok('未授权的连接一律拒绝(默认拒绝)', async () => {
299
+ const r = await manager.query(connId, 'SELECT 1', [], { projectKey: 'no-such-project' });
300
+ assert.equal(r.ok, false, JSON.stringify(r));
301
+ assert.equal(r.code, 'NOT_AUTHORIZED');
302
+ });
303
+
304
+ await ok('只读授权:能查、不能写', async () => {
305
+ grant(connId, 'readonly');
306
+ const read = await manager.query(connId, 'SELECT 1 AS one', [], { projectKey: PROJECT });
307
+ assert.equal(read.ok, true, JSON.stringify(read));
308
+ const write = await manager.query(connId, 'CREATE TABLE nope (a INT)', [], { readonly: false, projectKey: PROJECT });
309
+ assert.equal(write.ok, false);
310
+ assert.equal(write.code, 'PERMISSION_DENIED', JSON.stringify(write));
311
+ });
312
+
313
+ await ok('授权等级可升级/撤销', () => {
314
+ grant(connId, 'readwrite');
315
+ assert.equal(manager.accessOf(PROJECT)[connId], 'readwrite');
316
+ manager.setAccess(PROJECT, connId, 'readonly');
317
+ assert.equal(manager.accessOf(PROJECT)[connId], 'readonly');
318
+ grant(connId, 'readwrite');
319
+ });
320
+
321
+ await ok('testConnection 能试连成功', async () => {
322
+ const result = await manager.testConnection(connId);
323
+ assert.equal(result.ok, true, JSON.stringify(result));
324
+ assert.equal(typeof result.durationMs, 'number');
325
+ });
326
+
327
+ await ok('建表、插入、参数化查询', async () => {
328
+ const create = await manager.query(connId, 'CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)', [], { readonly: false, projectKey: PROJECT });
329
+ assert.equal(create.ok, true, JSON.stringify(create));
330
+
331
+ const ins = await manager.query(connId, 'INSERT INTO users (name, age) VALUES (?, ?)', ['alice', 30], { readonly: false, projectKey: PROJECT });
332
+ assert.equal(ins.ok, true, JSON.stringify(ins));
333
+ assert.equal(ins.affectedRows, 1);
334
+
335
+ await manager.query(connId, 'INSERT INTO users (name, age) VALUES (?, ?)', ['bob', 25], { readonly: false, projectKey: PROJECT });
336
+
337
+ const sel = await manager.query(connId, 'SELECT * FROM users ORDER BY id', [], { projectKey: PROJECT });
338
+ assert.equal(sel.ok, true, JSON.stringify(sel));
339
+ assert.deepEqual(sel.columns, ['id', 'name', 'age']);
340
+ assert.equal(sel.rowCount, 2);
341
+ assert.equal(sel.rows[0].name, 'alice');
342
+ assert.equal(sel.rows[1].age, 25);
343
+ });
344
+
345
+ await ok('参数化能防注入(值里的引号不生效)', async () => {
346
+ const evil = "x' OR '1'='1";
347
+ const ins = await manager.query(connId, 'INSERT INTO users (name, age) VALUES (?, ?)', [evil, 1], { readonly: false, projectKey: PROJECT });
348
+ assert.equal(ins.ok, true);
349
+ const sel = await manager.query(connId, 'SELECT * FROM users WHERE name = ?', [evil], { projectKey: PROJECT });
350
+ assert.equal(sel.rowCount, 1, '应精确匹配那个字面值,而不是被 OR 恒真放大');
351
+ assert.equal(sel.rows[0].name, evil);
352
+ });
353
+
354
+ await ok('只读模式先拦写操作(readonly 是硬闸门)', async () => {
355
+ manager.setAccess(PROJECT, connId, 'readonly');
356
+ const del = await manager.query(connId, 'DELETE FROM users', [], { readonly: true, projectKey: PROJECT });
357
+ assert.equal(del.ok, false);
358
+ // readonly:true 时,只读闸门先于授权检查生效
359
+ assert.ok(['READONLY_VIOLATION', 'PERMISSION_DENIED'].includes(del.code), JSON.stringify(del));
360
+
361
+ // 确认数据没被删
362
+ const count = await manager.query(connId, 'SELECT COUNT(*) AS n FROM users', [], { projectKey: PROJECT });
363
+ assert.equal(count.rows[0].n, 3);
364
+ grant(connId, 'readwrite');
365
+ });
366
+
367
+ await ok('多语句被拦截', async () => {
368
+ const multi = await manager.query(connId, 'SELECT 1; DELETE FROM users', [], { readonly: false, projectKey: PROJECT });
369
+ assert.equal(multi.ok, false);
370
+ assert.equal(multi.code, 'MULTI_STATEMENT');
371
+ });
372
+
373
+ await ok('空 SQL 给出明确错误', async () => {
374
+ const empty = await manager.query(connId, ' ', [], { projectKey: PROJECT });
375
+ assert.equal(empty.ok, false);
376
+ assert.equal(empty.code, 'EMPTY_SQL');
377
+ });
378
+
379
+ await ok('语法错误转成 DB_ERROR,不抛异常', async () => {
380
+ const bad = await manager.query(connId, 'SELEC * FROM users', [], { projectKey: PROJECT });
381
+ assert.equal(bad.ok, false);
382
+ assert.equal(bad.code, 'DB_ERROR');
383
+ assert.equal(typeof bad.message, 'string');
384
+ assert.ok(bad.message.length > 0);
385
+ });
386
+
387
+ await ok('不存在的连接:先被授权层拒绝,不会泄漏「有没有这个连接」', async () => {
388
+ const result = await manager.query('no-such-conn', 'SELECT 1', [], { projectKey: PROJECT });
389
+ assert.equal(result.ok, false);
390
+ // 授权层先于连接查找,所以未授权时一律 NOT_AUTHORIZED——
391
+ // 这本身也是个安全特性:不让人探测「哪些连接 id 存在」
392
+ assert.ok(['NOT_AUTHORIZED', 'NOT_FOUND'].includes(result.code), JSON.stringify(result));
393
+ });
394
+
395
+ await ok('结果集按上限截断并提示', async () => {
396
+ const many = await manager.query(connId, 'SELECT * FROM users', [], { maxRows: 1, projectKey: PROJECT });
397
+ assert.equal(many.ok, true);
398
+ assert.equal(many.rows.length, 1, '应只返回 1 行');
399
+ assert.equal(many.rowCount, 3, 'rowCount 仍是真实总数');
400
+ assert.equal(many.truncated, true);
401
+ assert.match(many.note, /已截断/);
402
+ });
403
+
404
+ console.log('元数据:');
405
+ await ok('listTables 能列出表', async () => {
406
+ const result = await manager.listTables(connId, "", { projectKey: PROJECT });
407
+ assert.equal(result.ok, true, JSON.stringify(result));
408
+ const names = result.tables.map((t) => t.name);
409
+ assert.ok(names.includes('users'), `表清单应包含 users,实际:${names.join(',')}`);
410
+ });
411
+
412
+ await ok('describeTable 返回字段与索引', async () => {
413
+ await manager.query(connId, 'CREATE INDEX idx_users_name ON users(name)', [], { readonly: false, projectKey: PROJECT });
414
+ const result = await manager.describeTable(connId, 'users', { projectKey: PROJECT });
415
+ assert.equal(result.ok, true, JSON.stringify(result));
416
+ assert.equal(result.table, 'users');
417
+ const byName = new Map(result.columns.map((c) => [c.name, c]));
418
+ assert.ok(byName.has('id'));
419
+ assert.equal(byName.get('id').primaryKey, true);
420
+ assert.equal(byName.get('name').type, 'TEXT');
421
+ assert.ok(result.indexes.some((i) => i.name === 'idx_users_name'), `应有索引,实际:${JSON.stringify(result.indexes)}`);
422
+ });
423
+
424
+ await ok('describeTable 对不存在的表返回空字段而不是崩', async () => {
425
+ const result = await manager.describeTable(connId, 'no_such_table', { projectKey: PROJECT });
426
+ assert.equal(result.ok, true);
427
+ assert.equal(result.columns.length, 0);
428
+ });
429
+
430
+ console.log('ApiRunnerService 集成:');
431
+ await ok('describe 只列已授权的连接,未授权的不给模型看', async () => {
432
+ const service = new ApiRunnerService({ homeDir });
433
+ const saved = service.dbSave({ name: 'describe 库', type: 'sqlite', database: dbFile });
434
+ assert.equal(saved.ok, true, JSON.stringify(saved));
435
+
436
+ // 未授权时:不该出现在 describe 里(模型看不见,就不会去试)
437
+ const before = service.describe('');
438
+ assert.ok(Array.isArray(before.databases), 'describe 必须返回 databases 数组');
439
+ assert.equal(before.databases.some((item) => item.name === 'describe 库'), false,
440
+ '未授权的连接不应出现在 describe 里');
441
+
442
+ // 授权后才出现,并带上权限等级
443
+ service.dbManager.setAccess(PROJECT, saved.connection.id, 'readonly');
444
+ const after = service.describe('');
445
+ const hit = after.databases.find((item) => item.name === 'describe 库');
446
+ assert.ok(hit, '授权后应出现在 describe 里');
447
+ assert.equal(hit.type, 'sqlite');
448
+ assert.equal(hit.level, 'readonly', '应带上权限等级,模型据此知道能不能写');
449
+ assert.ok(hit.target.includes('test.db'), '应给出目标(文件路径)');
450
+
451
+ // 不能泄漏密码
452
+ const text = JSON.stringify(after);
453
+ assert.equal(/password/i.test(text), false, 'describe 不应包含 password 字段');
454
+ assert.equal(text.includes('secret'), false, 'describe 不应包含密码值');
455
+
456
+ service.dispose();
457
+ });
458
+
459
+ await ok('service 暴露数据库方法,且 dispose 关闭连接池', async () => {
460
+ const service = new ApiRunnerService({ homeDir });
461
+ const saved = service.dbSave({ name: '服务层库', type: 'sqlite', database: dbFile });
462
+ assert.equal(saved.ok, true, JSON.stringify(saved));
463
+
464
+ const list = service.dbConnections();
465
+ assert.equal(list.ok, true);
466
+ assert.ok(list.connections.some((c) => c.name === '服务层库'));
467
+
468
+ // 未授权时拒绝
469
+ const denied = await service.dbQuery(saved.connection.id, 'SELECT 1 AS one', [], { projectKey: PROJECT });
470
+ assert.equal(denied.ok, false);
471
+ assert.equal(denied.code, 'NOT_AUTHORIZED');
472
+
473
+ // 授权后可查
474
+ service.dbManager.setAccess(PROJECT, saved.connection.id, 'readonly');
475
+ const queried = await service.dbQuery(saved.connection.id, 'SELECT 1 AS one', [], { projectKey: PROJECT });
476
+ assert.equal(queried.ok, true, JSON.stringify(queried));
477
+ assert.equal(queried.rows[0].one, 1);
478
+
479
+ service.dispose();
480
+ });
481
+
482
+ await ok('service 在数据库模块缺失时给出 NO_DB', async () => {
483
+ const service = new ApiRunnerService({ homeDir });
484
+ service.dbManager = undefined;
485
+ const result = service.dbConnections();
486
+ assert.equal(result.ok, false);
487
+ assert.equal(result.code, 'NO_DB');
488
+ });
489
+
490
+ console.log('清理:');
491
+ await ok('删除连接后不再出现在列表里', () => {
492
+ // 前面「服务层库」是另一个 DbManager 实例写的,同一文件里还在;
493
+ // 这里先把它清掉,只针对本实例创建的 connId 做断言
494
+ for (const item of manager.listConnections()) manager.deleteConnection(item.id);
495
+ assert.equal(manager.listConnections().length, 0);
496
+
497
+ // 再保存一个,验证删除语义
498
+ const saved = manager.saveConnection({ name: '待删除', type: 'sqlite', database: dbFile });
499
+ assert.equal(saved.ok, true);
500
+ const del = manager.deleteConnection(saved.connection.id);
501
+ assert.equal(del.ok, true);
502
+ assert.equal(manager.listConnections().length, 0, '删除后不应再出现在列表里');
503
+
504
+ const again = manager.deleteConnection(saved.connection.id);
505
+ assert.equal(again.ok, false);
506
+ assert.equal(again.code, 'NOT_FOUND');
507
+ });
508
+
509
+ manager.dispose();
510
+ fs.rmSync(tmp, { recursive: true, force: true });
511
+
512
+ console.log(`\n${process.exitCode ? 'FAILED' : 'PASSED'}: ${passed} checks`);
package/test/plugin.mjs CHANGED
@@ -428,7 +428,17 @@ await ok('java/start-all 与状态轮询路由都已挂载', async () => {
428
428
  assert.equal(statusPayload.code, 'NO_BATCH');
429
429
  });
430
430
 
431
- await ok('工具 action 里有 login 与 java', () => {
431
+ await ok('工具描述里提到数据库,模型才会主动用', () => {
432
+ const tool = registered.tools[0];
433
+ const desc = String(tool.description || '');
434
+ assert.ok(/数据库/.test(desc), '工具描述应提到数据库');
435
+ assert.ok(/action=db/.test(desc), '工具描述应说明 action=db 的用法');
436
+ assert.ok(/什么时候该用 db/.test(desc), '应说明什么场景该查数据库(否则模型不会主动查)');
437
+ assert.ok(/describe_table/.test(desc), '应提示先看表结构');
438
+ assert.ok(/db_params/.test(desc), '应提示用参数化传值');
439
+ });
440
+
441
+ await ok('工具描述里提到 login 与 java', () => {
432
442
  const tool = registered.tools[0];
433
443
  const actions = tool.parameters.properties.action.enum;
434
444
  assert.ok(actions.includes('login'));
@@ -438,6 +448,145 @@ await ok('工具 action 里有 login 与 java', () => {
438
448
  assert.ok(tool.parameters.properties.java_ids, '应有 java_ids 参数');
439
449
  });
440
450
 
451
+ await ok('工具支持 db action 与相关参数', () => {
452
+ const tool = registered.tools[0];
453
+ const props = tool.parameters.properties;
454
+ assert.ok(props.action.enum.includes('db'), 'action 应包含 db');
455
+ assert.ok(props.db_action, '应有 db_action 参数');
456
+ for (const action of ['list_connections', 'test', 'save', 'delete', 'query', 'list_tables', 'describe_table']) {
457
+ assert.ok(props.db_action.enum.includes(action), `db_action 应包含 ${action}`);
458
+ }
459
+ assert.ok(props.connection_id, '应有 connection_id');
460
+ assert.ok(props.sql, '应有 sql');
461
+ assert.ok(props.db_params, '应有 db_params');
462
+ assert.ok(props.db_readonly, '应有 db_readonly');
463
+ });
464
+
465
+ await ok('db 路由都已挂载', async () => {
466
+ const route = registered.routes[0];
467
+ const get = (path) => ({
468
+ url: path,
469
+ method: 'GET',
470
+ headers: {},
471
+ async *[Symbol.asyncIterator]() {},
472
+ });
473
+ const post = (path, payload) => ({
474
+ url: path,
475
+ method: 'POST',
476
+ headers: {},
477
+ async *[Symbol.asyncIterator]() {
478
+ yield Buffer.from(JSON.stringify(payload), 'utf8');
479
+ },
480
+ });
481
+
482
+ // 连接列表:空环境应返回空数组而不是 404
483
+ const list = createMockResponse();
484
+ await route.handler(get('/dsh-api-runner/api/db'), list);
485
+ const listPayload = JSON.parse(list.body);
486
+ assert.equal(listPayload.ok, true, list.body);
487
+ assert.ok(Array.isArray(listPayload.connections));
488
+
489
+ // 缺参数时给出明确错误,不是 404
490
+ const noId = createMockResponse();
491
+ await route.handler(post('/dsh-api-runner/api/db/query', { sql: 'SELECT 1' }), noId);
492
+ const noIdPayload = JSON.parse(noId.body);
493
+ assert.equal(noIdPayload.ok, false, noId.body);
494
+ assert.equal(noIdPayload.code, 'MISSING_CONNECTION', '空 connection_id 应给出明确错误而不是路由缺失');
495
+
496
+ // 表结构路由存在
497
+ const table = createMockResponse();
498
+ await route.handler(get('/dsh-api-runner/api/db/table?connection_id=nope&name=t'), table);
499
+ const tablePayload = JSON.parse(table.body);
500
+ assert.equal(tablePayload.ok, false, table.body);
501
+ // 未授权时一律 NOT_AUTHORIZED(不泄漏连接是否存在)
502
+ assert.ok(['NOT_AUTHORIZED', 'NOT_FOUND'].includes(tablePayload.code), table.body);
503
+ });
504
+
505
+ await ok('db 工具动作能跑通 SQLite 全链路(模型侧只读)', async () => {
506
+ const tool = registered.tools[0];
507
+ const dbFile = path.join(tmp, 'tool-test.db');
508
+ const exec = (args) => tool.execute(args, { agent: { session: { id: 's1' } } });
509
+
510
+ // 用 service 直接建表并写入(模拟「人在面板里操作」),因为模型侧不允许写
511
+ const service = new (await import(pathToFileURL(path.join(root, 'src', 'ops.js')).href)).ApiRunnerService({ homeDir });
512
+ const seeded = service.dbSave({ name: '工具测试库', type: 'sqlite', database: dbFile });
513
+ assert.equal(seeded.ok, true, JSON.stringify(seeded));
514
+ const connId = seeded.connection.id;
515
+
516
+ // 授权:先给读写来播种数据(模拟「人在面板里操作」),之后降为只读
517
+ const projectKey = normalizeProjectKey(projectDir) || 'default';
518
+ service.dbManager.setAccess(projectKey, connId, 'readwrite');
519
+ await service.dbQuery(connId, 'CREATE TABLE items (id INTEGER PRIMARY KEY, label TEXT)', [], { projectKey, readonly: false });
520
+ await service.dbQuery(connId, 'INSERT INTO items (label) VALUES (?)', ['hello'], { projectKey, readonly: false });
521
+ // 播种完降为只读:模型侧本来就强制只读,这里给出最小权限
522
+ service.dbManager.setAccess(projectKey, connId, 'readonly');
523
+ service.dispose();
524
+
525
+ // 模型侧查询
526
+ const queried = await exec({ action: 'db', db_action: 'query', connection_id: connId, sql: 'SELECT * FROM items' });
527
+ const q = JSON.parse(queried.text);
528
+ assert.equal(q.ok, true, queried.text);
529
+ assert.equal(q.rowCount, 1);
530
+ assert.equal(q.rows[0].label, 'hello');
531
+
532
+ // 模型侧写操作一律被拒(即使传 db_readonly=false)
533
+ for (const sql of ['DELETE FROM items WHERE id = 1', 'DROP TABLE items', 'INSERT INTO items (label) VALUES (?)']) {
534
+ const blocked = await exec({
535
+ action: 'db', db_action: 'query', connection_id: connId,
536
+ sql,
537
+ db_params: ['x'],
538
+ db_readonly: false, // 显式传 false 也没用
539
+ });
540
+ const b = JSON.parse(blocked.text);
541
+ assert.equal(b.ok, false, `模型不应能执行写操作:${sql} → ${blocked.text}`);
542
+ assert.ok(['READONLY_VIOLATION', 'PERMISSION_DENIED', 'DESTRUCTIVE_BLOCKED'].includes(b.code), blocked.text);
543
+ assert.equal(blocked.isError, true);
544
+ }
545
+
546
+ // 数据没被改
547
+ const after = await exec({ action: 'db', db_action: 'query', connection_id: connId, sql: 'SELECT COUNT(*) AS n FROM items' });
548
+ assert.equal(JSON.parse(after.text).rows[0].n, 1, '数据不应被模型改动');
549
+
550
+ // 元数据(只读,允许)
551
+ const tables = await exec({ action: 'db', db_action: 'list_tables', connection_id: connId });
552
+ const t = JSON.parse(tables.text);
553
+ assert.equal(t.ok, true, tables.text);
554
+ assert.ok(t.tables.some((x) => x.name === 'items'));
555
+
556
+ const detail = await exec({ action: 'db', db_action: 'describe_table', connection_id: connId, table: 'items' });
557
+ const d = JSON.parse(detail.text);
558
+ assert.equal(d.ok, true, detail.text);
559
+ assert.ok(d.columns.some((c) => c.name === 'label'));
560
+
561
+ // 清理
562
+ const cleanup = new (await import(pathToFileURL(path.join(root, 'src', 'ops.js')).href)).ApiRunnerService({ homeDir });
563
+ cleanup.dbDelete(connId);
564
+ cleanup.dispose();
565
+ });
566
+
567
+ await ok('模型侧对未授权连接完全不可见、不可查', async () => {
568
+ const tool = registered.tools[0];
569
+ const dbFile = path.join(tmp, 'unauth.db');
570
+ const service = new (await import(pathToFileURL(path.join(root, 'src', 'ops.js')).href)).ApiRunnerService({ homeDir });
571
+ const saved = service.dbSave({ name: '未授权库', type: 'sqlite', database: dbFile });
572
+ assert.equal(saved.ok, true);
573
+ const connId = saved.connection.id;
574
+
575
+ // describe 里看不到
576
+ const info = service.describe(projectDir);
577
+ assert.equal((info.databases || []).some((d) => d.id === connId), false, '未授权连接不应出现在 describe 里');
578
+
579
+ // 直接查也被拒
580
+ const exec = (args) => tool.execute(args, { agent: { session: { id: 's1' } } });
581
+ const r = await exec({ action: 'db', db_action: 'query', connection_id: connId, sql: 'SELECT 1' });
582
+ const payload = JSON.parse(r.text);
583
+ assert.equal(payload.ok, false);
584
+ assert.equal(payload.code, 'NOT_AUTHORIZED');
585
+
586
+ service.dbDelete(connId);
587
+ service.dispose();
588
+ });
589
+
441
590
  // ---- 清理 ----
442
591
  for (const effect of registered.effects) effect.dispose?.();
443
592
  server.close();