import { NetaClawDataSourceEntity } from '../src/modules/netaclaw/entity/data_source.js'; import { MysqlQueryService } from '../src/modules/netaclaw/service/mysql_query.js'; function makeSource(overrides: Partial = {}) { return Object.assign(new NetaClawDataSourceEntity(), { id: 42, name: 'orders', type: 'mysql', database: 'shop', extra: { allowedTables: ['customers', 'orders'], blockedTables: [], maxRows: 2, maxJoinTables: 4, maskedColumns: { 'customers.phone': 'partial' }, queryTimeoutMs: 5000, }, ...overrides, }); } function makeService(pool: any, auditRepo: any) { const service = new MysqlQueryService(); service.mysqlPoolManager = { getPool: jest.fn().mockResolvedValue(pool), } as any; service.auditRepo = auditRepo; return service; } describe('MysqlQueryService', () => { it('executes guarded query and writes success audit', async () => { const execute = jest.fn().mockResolvedValue([ [{ id: 1, name: 'Ada' }], [{ name: 'id' }, { name: 'name' }], ]); const auditRepo = { save: jest.fn().mockResolvedValue({}) }; const service = makeService({ execute }, auditRepo); const result = await service.executeReadOnly({ source: makeSource(), sql: 'SELECT id, name FROM customers LIMIT 1', agentId: 7, userId: 8, toolCallId: 'tool-1', }); expect(execute).toHaveBeenCalledWith( { sql: 'SELECT id, name FROM customers LIMIT 1', timeout: 5000 }, [] ); expect(result).toMatchObject({ columns: ['id', 'name'], rows: [{ id: 1, name: 'Ada' }], rowCount: 1, truncated: false, sql: 'SELECT id, name FROM customers LIMIT 1', }); expect(result.elapsedMs).toEqual(expect.any(Number)); expect(auditRepo.save).toHaveBeenCalledWith(expect.objectContaining({ dataSourceId: 42, agentId: 7, userId: 8, toolCallId: 'tool-1', status: 'success', rejectReason: null, rowCount: 1, errorCode: null, sqlPreview: 'SELECT id, name FROM customers LIMIT 1', })); expect(auditRepo.save.mock.calls[0][0].sqlHash).toMatch(/^[a-f0-9]{64}$/); }); it('rejects masked column before querying MySQL and audits rejected', async () => { const execute = jest.fn(); const auditRepo = { save: jest.fn().mockResolvedValue({}) }; const service = makeService({ execute }, auditRepo); await expect(service.executeReadOnly({ source: makeSource(), sql: 'SELECT phone FROM customers LIMIT 1', })).rejects.toThrow('mysql_sql_rejected: masked_column_denied'); expect(execute).not.toHaveBeenCalled(); expect(auditRepo.save).toHaveBeenCalledWith(expect.objectContaining({ status: 'rejected', rejectReason: 'masked_column_denied', rowCount: 0, errorCode: null, })); }); it('audits rejected SQL', async () => { const execute = jest.fn(); const auditRepo = { save: jest.fn().mockResolvedValue({}) }; const service = makeService({ execute }, auditRepo); await expect(service.executeReadOnly({ source: makeSource(), sql: 'DELETE FROM customers', agentId: null, userId: null, toolCallId: null, })).rejects.toThrow('mysql_sql_rejected: dml_sql_denied'); expect(execute).not.toHaveBeenCalled(); expect(auditRepo.save).toHaveBeenCalledWith(expect.objectContaining({ dataSourceId: 42, agentId: null, userId: null, toolCallId: null, status: 'rejected', rejectReason: 'dml_sql_denied', rowCount: 0, })); }); it('uses no-limit wrapper params, truncates node-side, and audits fetched row count', async () => { const execute = jest.fn().mockResolvedValue([ [{ id: 1 }, { id: 2 }, { id: 3 }], [{ name: 'id' }], ]); const auditRepo = { save: jest.fn().mockResolvedValue({}) }; const service = makeService({ execute }, auditRepo); const result = await service.executeReadOnly({ source: makeSource(), sql: 'SELECT id FROM customers', }); expect(execute).toHaveBeenCalledWith( { sql: 'SELECT * FROM (SELECT id FROM customers) AS neta_limited_query LIMIT ?', timeout: 5000, }, [3] ); expect(result.rows).toEqual([{ id: 1 }, { id: 2 }]); expect(result.rowCount).toBe(2); expect(result.truncated).toBe(true); expect(auditRepo.save).toHaveBeenCalledWith(expect.objectContaining({ status: 'success', rowCount: 2, })); }); it('audits mysql failures with sanitized error codes', async () => { const execute = jest.fn().mockRejectedValue({ code: 'ER_PARSE_ERROR: detail' }); const auditRepo = { save: jest.fn().mockResolvedValue({}) }; const service = makeService({ execute }, auditRepo); await expect(service.executeReadOnly({ source: makeSource(), sql: 'SELECT id FROM customers LIMIT 1', })).rejects.toThrow('mysql_query_failed:ER_PARSE_ERROR_detail'); expect(auditRepo.save).toHaveBeenCalledWith(expect.objectContaining({ status: 'failed', rejectReason: null, rowCount: 0, errorCode: 'ER_PARSE_ERROR_detail', })); }); it('returns successful query results when success audit write fails', async () => { const execute = jest.fn().mockResolvedValue([ [{ id: 1 }], [{ name: 'id' }], ]); const auditRepo = { save: jest.fn().mockRejectedValue(new Error('audit_down')) }; const service = makeService({ execute }, auditRepo); await expect(service.executeReadOnly({ source: makeSource(), sql: 'SELECT id FROM customers LIMIT 1', })).resolves.toMatchObject({ rows: [{ id: 1 }], rowCount: 1, truncated: false, }); }); it('preserves guard rejection reason when rejected audit write fails', async () => { const execute = jest.fn(); const auditRepo = { save: jest.fn().mockRejectedValue(new Error('audit_down')) }; const service = makeService({ execute }, auditRepo); await expect(service.executeReadOnly({ source: makeSource(), sql: 'DELETE FROM customers', })).rejects.toThrow('mysql_sql_rejected: dml_sql_denied'); expect(execute).not.toHaveBeenCalled(); }); it('does not pass wrapper limit params for explicit LIMIT SQL containing a question mark literal', async () => { const execute = jest.fn().mockResolvedValue([ [{ id: 1 }], [{ name: 'id' }], ]); const auditRepo = { save: jest.fn().mockResolvedValue({}) }; const service = makeService({ execute }, auditRepo); await service.executeReadOnly({ source: makeSource(), sql: 'SELECT id FROM customers WHERE name = "?" LIMIT 1', }); expect(execute).toHaveBeenCalledWith( { sql: 'SELECT id FROM customers WHERE name = "?" LIMIT 1', timeout: 5000 }, [] ); }); it('does not pass wrapper limit params when explicit LIMIT SQL has surrounding whitespace', async () => { const execute = jest.fn().mockResolvedValue([ [{ id: 1 }], [{ name: 'id' }], ]); const auditRepo = { save: jest.fn().mockResolvedValue({}) }; const service = makeService({ execute }, auditRepo); await service.executeReadOnly({ source: makeSource(), sql: ' SELECT id FROM customers LIMIT 1 ', }); expect(execute).toHaveBeenCalledWith( { sql: 'SELECT id FROM customers LIMIT 1', timeout: 5000 }, [] ); }); it('passes wrapper limit params when LIMIT appears only in a string literal', async () => { const execute = jest.fn().mockResolvedValue([ [{ id: 1 }, { id: 2 }, { id: 3 }], [{ name: 'id' }], ]); const auditRepo = { save: jest.fn().mockResolvedValue({}) }; const service = makeService({ execute }, auditRepo); await service.executeReadOnly({ source: makeSource(), sql: "SELECT id FROM customers WHERE note = 'limit 1'", }); expect(execute).toHaveBeenCalledWith( { sql: "SELECT * FROM (SELECT id FROM customers WHERE note = 'limit 1') AS neta_limited_query LIMIT ?", timeout: 5000, }, [3] ); }); });