import { NetaClawDataSourceEntity } from '../src/modules/netaclaw/entity/data_source.js'; import { NetaClawDataSourceService } from '../src/modules/netaclaw/service/data_source.js'; function makeDataSource(overrides: Partial = {}) { return Object.assign(new NetaClawDataSourceEntity(), { id: 1, name: 'orders', label: 'Orders DB', type: 'mysql', host: 'db.internal', port: 3306, database: 'orders_prod', username: 'readonly', passwordEncrypted: 'encrypted-secret', readonly: true, status: 1, allowedAgentIds: [9], extra: { allowedTables: ['orders'], maskedColumns: { 'orders.phone': 'partial' }, }, ...overrides, }); } describe('NetaClawDataSourceService projections', () => { it('returns admin-safe metadata without password ciphertext', () => { const service = new NetaClawDataSourceService(); const result = service.toAdminSafe(makeDataSource()); expect(result).toMatchObject({ id: 1, name: 'orders', label: 'Orders DB', type: 'mysql', host: 'db.internal', port: 3306, database: 'orders_prod', username: 'readonly', readonly: true, status: 1, allowedAgentIds: [9], hasPassword: true, }); expect(result).not.toHaveProperty('passwordEncrypted'); }); it('redacts sensitive ssl material from admin-safe metadata', () => { const service = new NetaClawDataSourceService(); const result = service.toAdminSafe(makeDataSource({ extra: { ssl: { key: 'private-key', cert: 'client-cert', passphrase: 'ssl-secret', rejectUnauthorized: true, }, }, })); expect(result.extra?.ssl).toEqual({ enabled: true }); expect(JSON.stringify(result.extra)).not.toContain('private-key'); expect(JSON.stringify(result.extra)).not.toContain('client-cert'); expect(JSON.stringify(result.extra)).not.toContain('ssl-secret'); }); it('returns agent summaries without connection credentials or permissions', () => { const service = new NetaClawDataSourceService(); const result = service.toAgentSummary(makeDataSource()); expect(result).toEqual({ name: 'orders', label: 'Orders DB', database: 'orders_prod', status: 1, }); expect(result).not.toHaveProperty('host'); expect(result).not.toHaveProperty('username'); expect(result).not.toHaveProperty('passwordEncrypted'); expect(result).not.toHaveProperty('allowedAgentIds'); expect(result).not.toHaveProperty('extra'); }); it('lists only active mysql data sources allowed for the agent', async () => { const service = new NetaClawDataSourceService(); service.dataSourceRepo = { find: jest.fn().mockResolvedValue([ makeDataSource({ name: 'orders', allowedAgentIds: [9] }), makeDataSource({ name: 'finance', allowedAgentIds: [10] }), makeDataSource({ name: 'disabled', status: 0, allowedAgentIds: [9] }), ]), } as any; const result = await service.listForAgent(9); expect(service.dataSourceRepo.find).toHaveBeenCalledWith({ where: { type: 'mysql', status: 1 }, order: { id: 'DESC' }, }); expect(result).toEqual([ { name: 'orders', label: 'Orders DB', database: 'orders_prod', status: 1, }, ]); }); it('rejects create payloads missing required connection fields', async () => { const service = new NetaClawDataSourceService(); await expect(service.saveConfig({ name: 'saved', username: 'readonly', } as any)).rejects.toThrow('data_source_host_required'); await expect(service.testConnection({ name: 'saved', host: 'db.internal', username: 'readonly', } as any)).rejects.toThrow('data_source_database_required'); }); it('whitelists complete saveConfig input and returns admin-safe projection', async () => { const service = new NetaClawDataSourceService(); const saved = makeDataSource({ id: 2, name: 'saved' }); service.dataSourceRepo = { create: jest.fn(payload => Object.assign(new NetaClawDataSourceEntity(), payload)), save: jest.fn().mockImplementation(entity => Promise.resolve(Object.assign(saved, entity))), } as any; service.secretCrypto = { encryptText: jest.fn().mockReturnValue('new-ciphertext'), } as any; service.mysqlPoolManager = { closePool: jest.fn().mockResolvedValue(undefined), } as any; const result = await service.saveConfig({ name: 'saved', label: null, host: 'db.internal', database: 'orders_prod', username: 'readonly', allowedAgentIds: null, password: 'plain-secret', tenantId: 7, } as any); const createPayload = (service.dataSourceRepo.create as jest.Mock).mock.calls[0][0]; expect(createPayload).toMatchObject({ name: 'saved', label: null, type: 'mysql', readonly: true, status: 1, port: 3306, host: 'db.internal', database: 'orders_prod', username: 'readonly', allowedAgentIds: [], passwordEncrypted: 'new-ciphertext', }); expect(createPayload).not.toHaveProperty('tenantId'); expect(createPayload).not.toHaveProperty('createTime'); expect(createPayload).not.toHaveProperty('updateTime'); expect(service.secretCrypto.encryptText).toHaveBeenCalledWith('plain-secret'); expect(service.mysqlPoolManager.closePool).toHaveBeenCalledWith(2); expect(result).toMatchObject({ id: 2, name: 'saved', hasPassword: true, }); expect(result).not.toHaveProperty('passwordEncrypted'); }); it('gets only authorized active mysql data sources by name', async () => { const service = new NetaClawDataSourceService(); const source = makeDataSource({ allowedAgentIds: [9, 10] }); service.dataSourceRepo = { findOne: jest.fn().mockResolvedValue(source), } as any; const result = await service.getAuthorizedSource('orders', 9); expect(service.dataSourceRepo.findOne).toHaveBeenCalledWith({ where: { name: 'orders', type: 'mysql', status: 1 }, }); expect(result).toBe(source); }); it('rejects missing and unauthorized data sources', async () => { const service = new NetaClawDataSourceService(); service.dataSourceRepo = { findOne: jest.fn() .mockResolvedValueOnce(null) .mockResolvedValueOnce(makeDataSource({ allowedAgentIds: [10] })), } as any; await expect(service.getAuthorizedSource('missing', 9)).rejects.toThrow('data_source_not_found'); await expect(service.getAuthorizedSource('orders', 9)).rejects.toThrow('data_source_not_authorized'); }); it('normalizes extra limits and preserves connection options', () => { const service = new NetaClawDataSourceService(); expect(service.normalizeExtra({ queryTimeoutMs: 90000, maxRows: 0, maxJoinTables: 20, poolConnectionLimit: 20, ssl: true, connectTimeout: 500, })).toEqual({ allowedTables: [], blockedTables: [], maskedColumns: {}, schemaVisibility: 'allowed-only', queryTimeoutMs: 30000, maxRows: 1, maxJoinTables: 6, poolConnectionLimit: 10, ssl: true, connectTimeout: 1000, }); }); it('deep-normalizes extra table and masked-column policy', () => { const service = new NetaClawDataSourceService(); expect(service.normalizeExtra({ allowedTables: [' Orders ', 'ORDERS', '', 9 as any, 'Customers'], blockedTables: [' Audit ', null as any, 'audit', ''], maskedColumns: { ' Orders.Phone ': 'partial', 'badkey': 'redact', 'orders.secret': 'hide' as any, 'customers.email': 'hash', }, schemaVisibility: 'invalid' as any, })).toMatchObject({ allowedTables: ['orders', 'customers'], blockedTables: ['audit'], maskedColumns: { 'orders.phone': 'partial', 'customers.email': 'hash', }, schemaVisibility: 'allowed-only', }); }); it('merges partial extra edits with existing policy before saving', async () => { const service = new NetaClawDataSourceService(); const existing = makeDataSource({ id: 2, extra: { allowedTables: ['Orders'], blockedTables: ['Audit'], maskedColumns: { 'Orders.Phone': 'partial' }, schemaVisibility: 'all-names-only', maxRows: 200, }, }); service.dataSourceRepo = { findOne: jest.fn().mockResolvedValue(existing), create: jest.fn(payload => Object.assign(new NetaClawDataSourceEntity(), payload)), save: jest.fn().mockImplementation(entity => Promise.resolve(entity)), } as any; service.mysqlPoolManager = { closePool: jest.fn().mockResolvedValue(undefined), } as any; await service.saveConfig({ id: 2, extra: { maxRows: 100 } }); const payload = (service.dataSourceRepo.create as jest.Mock).mock.calls[0][0]; expect(payload.extra).toMatchObject({ allowedTables: ['orders'], blockedTables: ['audit'], maskedColumns: { 'orders.phone': 'partial' }, schemaVisibility: 'all-names-only', maxRows: 100, }); }); it('normalizes allowed agent ids when saving', async () => { const service = new NetaClawDataSourceService(); const saved = makeDataSource({ id: 2 }); service.dataSourceRepo = { create: jest.fn(payload => Object.assign(new NetaClawDataSourceEntity(), payload)), save: jest.fn().mockImplementation(entity => Promise.resolve(Object.assign(saved, entity))), } as any; service.mysqlPoolManager = { closePool: jest.fn().mockResolvedValue(undefined), } as any; await service.saveConfig({ name: 'saved', host: 'db.internal', database: 'orders_prod', username: 'readonly', allowedAgentIds: [9, '9', '10', { id: 11 }, Number.POSITIVE_INFINITY, 'bad'] as any, }); const payload = (service.dataSourceRepo.create as jest.Mock).mock.calls[0][0]; expect(payload.allowedAgentIds).toEqual([9, 10]); }); it('retains existing password and extra when editing without replacements', async () => { const service = new NetaClawDataSourceService(); const existing = makeDataSource({ id: 2, passwordEncrypted: 'existing-ciphertext', extra: { allowedTables: ['orders'], queryTimeoutMs: 1500, }, }); service.dataSourceRepo = { findOne: jest.fn().mockResolvedValue(existing), create: jest.fn(payload => Object.assign(new NetaClawDataSourceEntity(), payload)), save: jest.fn().mockImplementation(entity => Promise.resolve(entity)), } as any; service.secretCrypto = { encryptText: jest.fn(), } as any; service.mysqlPoolManager = { closePool: jest.fn().mockResolvedValue(undefined), } as any; await service.saveConfig({ id: 2, label: 'Updated label' }); const payload = (service.dataSourceRepo.create as jest.Mock).mock.calls[0][0]; expect(payload.passwordEncrypted).toBe('existing-ciphertext'); expect(payload.extra).toMatchObject({ allowedTables: ['orders'], queryTimeoutMs: 1500, maxRows: 200, }); expect(service.secretCrypto.encryptText).not.toHaveBeenCalled(); }); it('tests connections with a transient pool and sanitized errors', async () => { const service = new NetaClawDataSourceService(); const accessDeniedError = Object.assign( new Error('Access denied for user readonly@db.internal using password secret'), { code: 'ER_ACCESS_DENIED_ERROR' } ); const transientPool = { query: jest.fn() .mockResolvedValueOnce([[{ ok: 1 }]]) .mockRejectedValueOnce(Object.assign(new Error('connect ECONNREFUSED db.internal readonly secret'), { code: 'ECONNREFUSED' })), end: jest.fn().mockResolvedValue(undefined), }; service.dataSourceRepo = { findOne: jest.fn().mockResolvedValue(makeDataSource({ id: 2, passwordEncrypted: 'existing-ciphertext' })), } as any; service.mysqlPoolManager = { createTransientPool: jest.fn() .mockResolvedValueOnce(transientPool) .mockResolvedValueOnce(transientPool) .mockRejectedValueOnce(accessDeniedError), } as any; await expect(service.testConnection({ id: 2, name: 'orders' })).resolves.toEqual({ ok: true }); await expect(service.testConnection({ id: 2, name: 'orders' })).resolves.toEqual({ ok: false, error: 'mysql_connection_failed:ECONNREFUSED', }); await expect(service.testConnection({ id: 2, name: 'orders' })).resolves.toEqual({ ok: false, error: 'mysql_connection_failed:ER_ACCESS_DENIED_ERROR', }); expect(transientPool.query).toHaveBeenCalledWith('SELECT 1 AS ok'); expect(transientPool.end).toHaveBeenCalledTimes(2); const testedSource = (service.mysqlPoolManager.createTransientPool as jest.Mock).mock.calls[0][0]; expect(testedSource.passwordEncrypted).toBe('existing-ciphertext'); }); });