GPU_GUARD_MONOREPO/packages/backend/test/data_source_projection.test.ts

381 lines
13 KiB
TypeScript
Raw Normal View History

2026-05-20 21:39:12 +08:00
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');
});
});