381 lines
13 KiB
TypeScript
381 lines
13 KiB
TypeScript
import { NetaClawDataSourceEntity } from '../src/modules/netaclaw/entity/data_source.js';
|
|
import { NetaClawDataSourceService } from '../src/modules/netaclaw/service/data_source.js';
|
|
|
|
function makeDataSource(overrides: Partial<NetaClawDataSourceEntity> = {}) {
|
|
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');
|
|
});
|
|
});
|