dsh-api-runner 0.3.0 → 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/README.md +35 -4
- package/client/client.js +673 -329
- package/package.json +6 -4
- package/scripts/check-leak.mjs +150 -0
- package/src/db.js +543 -37
- package/src/main.js +77 -13
- package/src/ops.js +46 -8
- package/test/client.mjs +41 -2
- package/test/db.mjs +190 -24
- package/test/plugin.mjs +76 -50
- package/test/security.mjs +200 -0
package/test/db.mjs
CHANGED
|
@@ -42,6 +42,17 @@ const dbFile = path.join(tmp, 'test.db');
|
|
|
42
42
|
|
|
43
43
|
const manager = new DbManager({ homeDir });
|
|
44
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
|
+
|
|
45
56
|
console.log('SQL 解析:');
|
|
46
57
|
await ok('单条语句不误判', () => {
|
|
47
58
|
assert.deepEqual(splitStatements('SELECT 1'), ['SELECT 1']);
|
|
@@ -161,6 +172,96 @@ await ok('支持达梦(dm)类型,默认端口 5236', () => {
|
|
|
161
172
|
manager.deleteConnection(saved.connection.id);
|
|
162
173
|
});
|
|
163
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
|
+
|
|
164
265
|
await ok('DB_TYPES 包含 dm 与 oracle', async () => {
|
|
165
266
|
const { DB_TYPES } = await load('src/db.js');
|
|
166
267
|
assert.ok(DB_TYPES.includes('dm'), '应支持达梦');
|
|
@@ -179,8 +280,10 @@ await ok('缺少 dmdb 驱动时给出可操作的提示,不崩', async () => {
|
|
|
179
280
|
username: 'SYSDBA',
|
|
180
281
|
});
|
|
181
282
|
assert.equal(saved.ok, true);
|
|
283
|
+
// 先授权,才能走到「尝试连接」这一步(否则会先被授权层拦掉)
|
|
284
|
+
grant(saved.connection.id, 'readonly');
|
|
182
285
|
// 本机可能装了 dmdb 也可能没装;两种情况都必须是「结构化失败」而不是抛异常
|
|
183
|
-
const result = await manager.query(saved.connection.id, 'SELECT 1', [], {});
|
|
286
|
+
const result = await manager.query(saved.connection.id, 'SELECT 1', [], { projectKey: PROJECT });
|
|
184
287
|
if (result.ok === false) {
|
|
185
288
|
assert.ok(['CONNECT_FAILED', 'DB_ERROR'].includes(result.code), JSON.stringify(result));
|
|
186
289
|
assert.equal(typeof result.message, 'string');
|
|
@@ -192,6 +295,29 @@ await ok('缺少 dmdb 驱动时给出可操作的提示,不崩', async () => {
|
|
|
192
295
|
});
|
|
193
296
|
|
|
194
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
|
+
|
|
195
321
|
await ok('testConnection 能试连成功', async () => {
|
|
196
322
|
const result = await manager.testConnection(connId);
|
|
197
323
|
assert.equal(result.ok, true, JSON.stringify(result));
|
|
@@ -199,16 +325,16 @@ await ok('testConnection 能试连成功', async () => {
|
|
|
199
325
|
});
|
|
200
326
|
|
|
201
327
|
await ok('建表、插入、参数化查询', async () => {
|
|
202
|
-
const create = await manager.query(connId, 'CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)', [], { readonly: false });
|
|
328
|
+
const create = await manager.query(connId, 'CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)', [], { readonly: false, projectKey: PROJECT });
|
|
203
329
|
assert.equal(create.ok, true, JSON.stringify(create));
|
|
204
330
|
|
|
205
|
-
const ins = await manager.query(connId, 'INSERT INTO users (name, age) VALUES (?, ?)', ['alice', 30], { readonly: false });
|
|
331
|
+
const ins = await manager.query(connId, 'INSERT INTO users (name, age) VALUES (?, ?)', ['alice', 30], { readonly: false, projectKey: PROJECT });
|
|
206
332
|
assert.equal(ins.ok, true, JSON.stringify(ins));
|
|
207
333
|
assert.equal(ins.affectedRows, 1);
|
|
208
334
|
|
|
209
|
-
await manager.query(connId, 'INSERT INTO users (name, age) VALUES (?, ?)', ['bob', 25], { readonly: false });
|
|
335
|
+
await manager.query(connId, 'INSERT INTO users (name, age) VALUES (?, ?)', ['bob', 25], { readonly: false, projectKey: PROJECT });
|
|
210
336
|
|
|
211
|
-
const sel = await manager.query(connId, 'SELECT * FROM users ORDER BY id', [], {});
|
|
337
|
+
const sel = await manager.query(connId, 'SELECT * FROM users ORDER BY id', [], { projectKey: PROJECT });
|
|
212
338
|
assert.equal(sel.ok, true, JSON.stringify(sel));
|
|
213
339
|
assert.deepEqual(sel.columns, ['id', 'name', 'age']);
|
|
214
340
|
assert.equal(sel.rowCount, 2);
|
|
@@ -218,51 +344,56 @@ await ok('建表、插入、参数化查询', async () => {
|
|
|
218
344
|
|
|
219
345
|
await ok('参数化能防注入(值里的引号不生效)', async () => {
|
|
220
346
|
const evil = "x' OR '1'='1";
|
|
221
|
-
const ins = await manager.query(connId, 'INSERT INTO users (name, age) VALUES (?, ?)', [evil, 1], { readonly: false });
|
|
347
|
+
const ins = await manager.query(connId, 'INSERT INTO users (name, age) VALUES (?, ?)', [evil, 1], { readonly: false, projectKey: PROJECT });
|
|
222
348
|
assert.equal(ins.ok, true);
|
|
223
|
-
const sel = await manager.query(connId, 'SELECT * FROM users WHERE name = ?', [evil], {});
|
|
349
|
+
const sel = await manager.query(connId, 'SELECT * FROM users WHERE name = ?', [evil], { projectKey: PROJECT });
|
|
224
350
|
assert.equal(sel.rowCount, 1, '应精确匹配那个字面值,而不是被 OR 恒真放大');
|
|
225
351
|
assert.equal(sel.rows[0].name, evil);
|
|
226
352
|
});
|
|
227
353
|
|
|
228
|
-
await ok('
|
|
229
|
-
|
|
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 });
|
|
230
357
|
assert.equal(del.ok, false);
|
|
231
|
-
|
|
358
|
+
// readonly:true 时,只读闸门先于授权检查生效
|
|
359
|
+
assert.ok(['READONLY_VIOLATION', 'PERMISSION_DENIED'].includes(del.code), JSON.stringify(del));
|
|
232
360
|
|
|
233
361
|
// 确认数据没被删
|
|
234
|
-
const count = await manager.query(connId, 'SELECT COUNT(*) AS n FROM users', [], {});
|
|
362
|
+
const count = await manager.query(connId, 'SELECT COUNT(*) AS n FROM users', [], { projectKey: PROJECT });
|
|
235
363
|
assert.equal(count.rows[0].n, 3);
|
|
364
|
+
grant(connId, 'readwrite');
|
|
236
365
|
});
|
|
237
366
|
|
|
238
367
|
await ok('多语句被拦截', async () => {
|
|
239
|
-
const multi = await manager.query(connId, 'SELECT 1; DELETE FROM users', [], { readonly: false });
|
|
368
|
+
const multi = await manager.query(connId, 'SELECT 1; DELETE FROM users', [], { readonly: false, projectKey: PROJECT });
|
|
240
369
|
assert.equal(multi.ok, false);
|
|
241
370
|
assert.equal(multi.code, 'MULTI_STATEMENT');
|
|
242
371
|
});
|
|
243
372
|
|
|
244
373
|
await ok('空 SQL 给出明确错误', async () => {
|
|
245
|
-
const empty = await manager.query(connId, ' ', [], {});
|
|
374
|
+
const empty = await manager.query(connId, ' ', [], { projectKey: PROJECT });
|
|
246
375
|
assert.equal(empty.ok, false);
|
|
247
376
|
assert.equal(empty.code, 'EMPTY_SQL');
|
|
248
377
|
});
|
|
249
378
|
|
|
250
379
|
await ok('语法错误转成 DB_ERROR,不抛异常', async () => {
|
|
251
|
-
const bad = await manager.query(connId, 'SELEC * FROM users', [], {});
|
|
380
|
+
const bad = await manager.query(connId, 'SELEC * FROM users', [], { projectKey: PROJECT });
|
|
252
381
|
assert.equal(bad.ok, false);
|
|
253
382
|
assert.equal(bad.code, 'DB_ERROR');
|
|
254
383
|
assert.equal(typeof bad.message, 'string');
|
|
255
384
|
assert.ok(bad.message.length > 0);
|
|
256
385
|
});
|
|
257
386
|
|
|
258
|
-
await ok('
|
|
259
|
-
const result = await manager.query('no-such-conn', 'SELECT 1', [], {});
|
|
387
|
+
await ok('不存在的连接:先被授权层拒绝,不会泄漏「有没有这个连接」', async () => {
|
|
388
|
+
const result = await manager.query('no-such-conn', 'SELECT 1', [], { projectKey: PROJECT });
|
|
260
389
|
assert.equal(result.ok, false);
|
|
261
|
-
|
|
390
|
+
// 授权层先于连接查找,所以未授权时一律 NOT_AUTHORIZED——
|
|
391
|
+
// 这本身也是个安全特性:不让人探测「哪些连接 id 存在」
|
|
392
|
+
assert.ok(['NOT_AUTHORIZED', 'NOT_FOUND'].includes(result.code), JSON.stringify(result));
|
|
262
393
|
});
|
|
263
394
|
|
|
264
395
|
await ok('结果集按上限截断并提示', async () => {
|
|
265
|
-
const many = await manager.query(connId, 'SELECT * FROM users', [], { maxRows: 1 });
|
|
396
|
+
const many = await manager.query(connId, 'SELECT * FROM users', [], { maxRows: 1, projectKey: PROJECT });
|
|
266
397
|
assert.equal(many.ok, true);
|
|
267
398
|
assert.equal(many.rows.length, 1, '应只返回 1 行');
|
|
268
399
|
assert.equal(many.rowCount, 3, 'rowCount 仍是真实总数');
|
|
@@ -272,15 +403,15 @@ await ok('结果集按上限截断并提示', async () => {
|
|
|
272
403
|
|
|
273
404
|
console.log('元数据:');
|
|
274
405
|
await ok('listTables 能列出表', async () => {
|
|
275
|
-
const result = await manager.listTables(connId);
|
|
406
|
+
const result = await manager.listTables(connId, "", { projectKey: PROJECT });
|
|
276
407
|
assert.equal(result.ok, true, JSON.stringify(result));
|
|
277
408
|
const names = result.tables.map((t) => t.name);
|
|
278
409
|
assert.ok(names.includes('users'), `表清单应包含 users,实际:${names.join(',')}`);
|
|
279
410
|
});
|
|
280
411
|
|
|
281
412
|
await ok('describeTable 返回字段与索引', async () => {
|
|
282
|
-
await manager.query(connId, 'CREATE INDEX idx_users_name ON users(name)', [], { readonly: false });
|
|
283
|
-
const result = await manager.describeTable(connId, 'users');
|
|
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 });
|
|
284
415
|
assert.equal(result.ok, true, JSON.stringify(result));
|
|
285
416
|
assert.equal(result.table, 'users');
|
|
286
417
|
const byName = new Map(result.columns.map((c) => [c.name, c]));
|
|
@@ -291,12 +422,40 @@ await ok('describeTable 返回字段与索引', async () => {
|
|
|
291
422
|
});
|
|
292
423
|
|
|
293
424
|
await ok('describeTable 对不存在的表返回空字段而不是崩', async () => {
|
|
294
|
-
const result = await manager.describeTable(connId, 'no_such_table');
|
|
425
|
+
const result = await manager.describeTable(connId, 'no_such_table', { projectKey: PROJECT });
|
|
295
426
|
assert.equal(result.ok, true);
|
|
296
427
|
assert.equal(result.columns.length, 0);
|
|
297
428
|
});
|
|
298
429
|
|
|
299
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
|
+
|
|
300
459
|
await ok('service 暴露数据库方法,且 dispose 关闭连接池', async () => {
|
|
301
460
|
const service = new ApiRunnerService({ homeDir });
|
|
302
461
|
const saved = service.dbSave({ name: '服务层库', type: 'sqlite', database: dbFile });
|
|
@@ -306,8 +465,15 @@ await ok('service 暴露数据库方法,且 dispose 关闭连接池', async ()
|
|
|
306
465
|
assert.equal(list.ok, true);
|
|
307
466
|
assert.ok(list.connections.some((c) => c.name === '服务层库'));
|
|
308
467
|
|
|
309
|
-
|
|
310
|
-
|
|
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));
|
|
311
477
|
assert.equal(queried.rows[0].one, 1);
|
|
312
478
|
|
|
313
479
|
service.dispose();
|
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('
|
|
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'));
|
|
@@ -481,72 +491,63 @@ await ok('db 路由都已挂载', async () => {
|
|
|
481
491
|
await route.handler(post('/dsh-api-runner/api/db/query', { sql: 'SELECT 1' }), noId);
|
|
482
492
|
const noIdPayload = JSON.parse(noId.body);
|
|
483
493
|
assert.equal(noIdPayload.ok, false, noId.body);
|
|
484
|
-
assert.equal(noIdPayload.code, '
|
|
494
|
+
assert.equal(noIdPayload.code, 'MISSING_CONNECTION', '空 connection_id 应给出明确错误而不是路由缺失');
|
|
485
495
|
|
|
486
496
|
// 表结构路由存在
|
|
487
497
|
const table = createMockResponse();
|
|
488
498
|
await route.handler(get('/dsh-api-runner/api/db/table?connection_id=nope&name=t'), table);
|
|
489
499
|
const tablePayload = JSON.parse(table.body);
|
|
490
500
|
assert.equal(tablePayload.ok, false, table.body);
|
|
491
|
-
|
|
501
|
+
// 未授权时一律 NOT_AUTHORIZED(不泄漏连接是否存在)
|
|
502
|
+
assert.ok(['NOT_AUTHORIZED', 'NOT_FOUND'].includes(tablePayload.code), table.body);
|
|
492
503
|
});
|
|
493
504
|
|
|
494
|
-
await ok('db 工具动作能跑通 SQLite
|
|
505
|
+
await ok('db 工具动作能跑通 SQLite 全链路(模型侧只读)', async () => {
|
|
495
506
|
const tool = registered.tools[0];
|
|
496
507
|
const dbFile = path.join(tmp, 'tool-test.db');
|
|
497
508
|
const exec = (args) => tool.execute(args, { agent: { session: { id: 's1' } } });
|
|
498
509
|
|
|
499
|
-
//
|
|
500
|
-
const
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
const
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
db_readonly: false,
|
|
517
|
-
});
|
|
518
|
-
assert.equal(JSON.parse(created.text).ok, true, created.text);
|
|
519
|
-
|
|
520
|
-
// 写入
|
|
521
|
-
const inserted = await exec({
|
|
522
|
-
action: 'db', db_action: 'query', connection_id: connId,
|
|
523
|
-
sql: 'INSERT INTO items (label) VALUES (?)',
|
|
524
|
-
db_params: ['hello'],
|
|
525
|
-
db_readonly: false,
|
|
526
|
-
});
|
|
527
|
-
assert.equal(JSON.parse(inserted.text).ok, true, inserted.text);
|
|
528
|
-
|
|
529
|
-
// 只读查询
|
|
530
|
-
const queried = await exec({
|
|
531
|
-
action: 'db', db_action: 'query', connection_id: connId,
|
|
532
|
-
sql: 'SELECT * FROM items',
|
|
533
|
-
});
|
|
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' });
|
|
534
527
|
const q = JSON.parse(queried.text);
|
|
535
528
|
assert.equal(q.ok, true, queried.text);
|
|
536
529
|
assert.equal(q.rowCount, 1);
|
|
537
530
|
assert.equal(q.rows[0].label, 'hello');
|
|
538
531
|
|
|
539
|
-
//
|
|
540
|
-
const
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
+
}
|
|
548
545
|
|
|
549
|
-
//
|
|
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
|
+
// 元数据(只读,允许)
|
|
550
551
|
const tables = await exec({ action: 'db', db_action: 'list_tables', connection_id: connId });
|
|
551
552
|
const t = JSON.parse(tables.text);
|
|
552
553
|
assert.equal(t.ok, true, tables.text);
|
|
@@ -558,7 +559,32 @@ await ok('db 工具动作能跑通 SQLite 全链路', async () => {
|
|
|
558
559
|
assert.ok(d.columns.some((c) => c.name === 'label'));
|
|
559
560
|
|
|
560
561
|
// 清理
|
|
561
|
-
await
|
|
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();
|
|
562
588
|
});
|
|
563
589
|
|
|
564
590
|
// ---- 清理 ----
|