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/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('只读模式拒绝写操作', async () => {
229
- const del = await manager.query(connId, 'DELETE FROM users', [], { readonly: true });
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
- assert.equal(del.code, 'READONLY_VIOLATION');
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('不存在的连接给出 NOT_FOUND', async () => {
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
- assert.equal(result.code, 'NOT_FOUND');
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
- const queried = await service.dbQuery(saved.connection.id, 'SELECT 1 AS one', [], {});
310
- assert.equal(queried.ok, true);
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('工具 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'));
@@ -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, 'NOT_FOUND', '空 connection_id 应走 NOT_FOUND 而不是路由缺失');
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
- assert.equal(tablePayload.code, 'NOT_FOUND');
501
+ // 未授权时一律 NOT_AUTHORIZED(不泄漏连接是否存在)
502
+ assert.ok(['NOT_AUTHORIZED', 'NOT_FOUND'].includes(tablePayload.code), table.body);
492
503
  });
493
504
 
494
- await ok('db 工具动作能跑通 SQLite 全链路', async () => {
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 saved = await exec({
501
- action: 'db',
502
- db_action: 'save',
503
- connection: { name: '工具测试库', type: 'sqlite', database: dbFile },
504
- });
505
- assert.equal(saved.isError, false, saved.text);
506
- const savedPayload = JSON.parse(saved.text);
507
- assert.equal(savedPayload.ok, true, saved.text);
508
- const connId = savedPayload.connection.id;
509
- assert.ok(connId);
510
- assert.equal(saved.text.includes('secret'), false, '输出里不应有密码字段');
511
-
512
- // 建表(只读关闭)
513
- const created = await exec({
514
- action: 'db', db_action: 'query', connection_id: connId,
515
- sql: 'CREATE TABLE items (id INTEGER PRIMARY KEY, label TEXT)',
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 blocked = await exec({
541
- action: 'db', db_action: 'query', connection_id: connId,
542
- sql: 'DELETE FROM items',
543
- });
544
- const b = JSON.parse(blocked.text);
545
- assert.equal(b.ok, false, blocked.text);
546
- assert.equal(b.code, 'READONLY_VIOLATION');
547
- assert.equal(blocked.isError, true, '失败时 isError 应为 true');
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 exec({ action: 'db', db_action: 'delete', connection_id: connId });
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
  // ---- 清理 ----