GPU_GUARD_MONOREPO/packages/backend/test/mysql_schema_mapper.test.ts

505 lines
15 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 {
MysqlIntrospectionService,
mapMysqlSchemaRows,
} from '../src/modules/netaclaw/service/mysql_schema.js';
function makeSource(overrides: Partial<NetaClawDataSourceEntity> = {}) {
return Object.assign(new NetaClawDataSourceEntity(), {
id: 42,
name: 'orders',
type: 'mysql',
database: 'shop',
extra: {
allowedTables: ['customers'],
blockedTables: [],
maskedColumns: { 'customers.phone': 'partial' },
},
...overrides,
});
}
describe('mysql schema mapper', () => {
it('maps camelCase columns, primary keys, indexes, foreign keys, and masked columns', () => {
const schema = mapMysqlSchemaRows({
maskedColumns: { 'customers.phone': 'partial' },
columns: [
{
tableName: 'customers',
tableComment: 'Customer records',
columnName: 'id',
ordinalPosition: 1,
columnType: 'bigint unsigned',
dataType: 'bigint',
isNullable: 'NO',
default: null,
columnComment: 'Primary id',
columnKey: 'PRI',
},
{
tableName: 'customers',
tableComment: 'Customer records',
columnName: 'phone',
ordinalPosition: 2,
columnType: 'varchar(32)',
dataType: 'varchar',
isNullable: 'YES',
default: null,
columnComment: 'Phone number',
columnKey: '',
},
{
tableName: 'orders',
tableComment: 'Orders',
columnName: 'customer_id',
ordinalPosition: 1,
columnType: 'bigint unsigned',
dataType: 'bigint',
isNullable: 'NO',
default: null,
columnComment: '',
columnKey: 'MUL',
},
],
indexes: [
{
tableName: 'customers',
indexName: 'PRIMARY',
nonUnique: 0,
seqInIndex: 1,
columnName: 'id',
},
{
tableName: 'orders',
indexName: 'idx_orders_customer',
nonUnique: 1,
seqInIndex: 1,
columnName: 'customer_id',
},
],
foreignKeys: [
{
tableName: 'orders',
constraintName: 'fk_orders_customer',
columnName: 'customer_id',
referencedTableName: 'customers',
referencedColumnName: 'id',
ordinalPosition: 1,
},
],
});
expect(schema).toEqual({
tables: [
{
name: 'customers',
comment: 'Customer records',
columns: [
{
name: 'id',
type: 'bigint unsigned',
dataType: 'bigint',
nullable: false,
default: null,
comment: 'Primary id',
ordinalPosition: 1,
},
{
name: 'phone',
type: 'varchar(32)',
dataType: 'varchar',
nullable: true,
default: null,
comment: 'Phone number',
ordinalPosition: 2,
masked: true,
maskMode: 'partial',
},
],
primaryKey: ['id'],
indexes: [
{
name: 'PRIMARY',
unique: true,
columns: ['id'],
},
],
foreignKeys: [],
},
{
name: 'orders',
comment: 'Orders',
columns: [
{
name: 'customer_id',
type: 'bigint unsigned',
dataType: 'bigint',
nullable: false,
default: null,
comment: '',
ordinalPosition: 1,
},
],
primaryKey: [],
indexes: [
{
name: 'idx_orders_customer',
unique: false,
columns: ['customer_id'],
},
],
foreignKeys: [
{
name: 'fk_orders_customer',
columns: ['customer_id'],
referencedTable: 'customers',
referencedColumns: ['id'],
},
],
},
],
});
});
it('keeps uppercase information_schema alias compatibility', () => {
const schema = mapMysqlSchemaRows({
columns: [
{
TABLE_NAME: 'customers',
TABLE_COMMENT: 'Customer records',
COLUMN_NAME: 'id',
ORDINAL_POSITION: 1,
COLUMN_TYPE: 'bigint unsigned',
DATA_TYPE: 'bigint',
IS_NULLABLE: 'NO',
COLUMN_DEFAULT: null,
COLUMN_COMMENT: 'Primary id',
COLUMN_KEY: 'PRI',
},
],
indexes: [
{
TABLE_NAME: 'customers',
INDEX_NAME: 'PRIMARY',
NON_UNIQUE: 0,
SEQ_IN_INDEX: 1,
COLUMN_NAME: 'id',
},
],
});
expect(schema.tables[0]).toMatchObject({
name: 'customers',
comment: 'Customer records',
columns: [
expect.objectContaining({
name: 'id',
type: 'bigint unsigned',
dataType: 'bigint',
nullable: false,
}),
],
primaryKey: ['id'],
indexes: [{ name: 'PRIMARY', unique: true, columns: ['id'] }],
});
});
});
describe('MysqlIntrospectionService sampleTable', () => {
it('defaults to unmasked visible columns when sampling a table with masked columns', async () => {
const query = jest.fn().mockResolvedValue([[{ id: 1, name: 'Ada' }]]);
const service = new MysqlIntrospectionService();
service.mysqlPoolManager = {
getPool: jest.fn().mockResolvedValue({ query }),
} as any;
service.listSchema = jest.fn().mockResolvedValue({
tables: [
{
name: 'customers',
comment: '',
columns: [
{ name: 'id', type: 'int', dataType: 'int', nullable: false, default: null, comment: '', ordinalPosition: 1 },
{ name: 'name', type: 'varchar(64)', dataType: 'varchar', nullable: true, default: null, comment: '', ordinalPosition: 2 },
{ name: 'phone', type: 'varchar(32)', dataType: 'varchar', nullable: true, default: null, comment: '', ordinalPosition: 3, masked: true, maskMode: 'partial' },
],
primaryKey: ['id'],
indexes: [],
foreignKeys: [],
},
],
});
const result = await service.sampleTable(makeSource(), { table: 'customers' });
expect(query).toHaveBeenCalledWith(
'SELECT `id`, `name` FROM `customers` LIMIT ?',
[5]
);
expect(result).toEqual({
columns: ['id', 'name'],
rows: [{ id: 1, name: 'Ada' }],
rowCount: 1,
truncated: false,
});
});
it('preserves original camelCase column names when default sampling', async () => {
const query = jest.fn().mockResolvedValue([[{ tenantId: null, isCrewMaster: 0 }]]);
const service = new MysqlIntrospectionService();
service.mysqlPoolManager = {
getPool: jest.fn().mockResolvedValue({ query }),
} as any;
service.listSchema = jest.fn().mockResolvedValue({
tables: [
{
name: 'netaclaw_agent',
comment: '',
columns: [
{ name: 'tenantId', type: 'bigint', dataType: 'bigint', nullable: true, default: null, comment: '', ordinalPosition: 1 },
{ name: 'isCrewMaster', type: 'tinyint', dataType: 'tinyint', nullable: false, default: 0, comment: '', ordinalPosition: 2 },
],
primaryKey: [],
indexes: [],
foreignKeys: [],
},
],
});
const result = await service.sampleTable(makeSource({
extra: {
allowedTables: ['netaclaw_agent'],
blockedTables: [],
},
}), { table: 'netaclaw_agent', limit: 20 });
expect(query).toHaveBeenCalledWith(
'SELECT `tenantId`, `isCrewMaster` FROM `netaclaw_agent` LIMIT ?',
[20]
);
expect(result.columns).toEqual(['tenantId', 'isCrewMaster']);
});
it('resolves explicit camelCase columns case-insensitively for sampling', async () => {
const query = jest.fn().mockResolvedValue([[{ tenantId: null }]]);
const service = new MysqlIntrospectionService();
service.mysqlPoolManager = {
getPool: jest.fn().mockResolvedValue({ query }),
} as any;
service.listSchema = jest.fn().mockResolvedValue({
tables: [
{
name: 'netaclaw_agent',
comment: '',
columns: [
{ name: 'tenantId', type: 'bigint', dataType: 'bigint', nullable: true, default: null, comment: '', ordinalPosition: 1 },
],
primaryKey: [],
indexes: [],
foreignKeys: [],
},
],
});
const result = await service.sampleTable(makeSource({
extra: {
allowedTables: ['netaclaw_agent'],
blockedTables: [],
},
}), { table: 'netaclaw_agent', columns: ['tenantid'] });
expect(query).toHaveBeenCalledWith(
'SELECT `tenantId` FROM `netaclaw_agent` LIMIT ?',
[5]
);
expect(result.columns).toEqual(['tenantId']);
});
it('rejects explicit masked columns when sampling', async () => {
const query = jest.fn();
const service = new MysqlIntrospectionService();
service.mysqlPoolManager = {
getPool: jest.fn().mockResolvedValue({ query }),
} as any;
service.listSchema = jest.fn().mockResolvedValue({
tables: [
{
name: 'customers',
comment: '',
columns: [
{ name: 'phone', type: 'varchar(32)', dataType: 'varchar', nullable: true, default: null, comment: '', ordinalPosition: 1, masked: true, maskMode: 'partial' },
],
primaryKey: [],
indexes: [],
foreignKeys: [],
},
],
});
await expect(service.sampleTable(makeSource(), {
table: 'customers',
columns: ['phone'],
})).rejects.toThrow('masked_column_denied');
expect(query).not.toHaveBeenCalled();
});
it('rejects default sampling when no unmasked columns remain', async () => {
const service = new MysqlIntrospectionService();
service.mysqlPoolManager = {
getPool: jest.fn(),
} as any;
service.listSchema = jest.fn().mockResolvedValue({
tables: [
{
name: 'customers',
comment: '',
columns: [
{ name: 'phone', type: 'varchar(32)', dataType: 'varchar', nullable: true, default: null, comment: '', ordinalPosition: 1, masked: true, maskMode: 'partial' },
],
primaryKey: [],
indexes: [],
foreignKeys: [],
},
],
});
await expect(service.sampleTable(makeSource(), {
table: 'customers',
})).rejects.toThrow('masked_column_denied');
});
it('rejects sampling unallowed name-only tables under all-names-only schema visibility', async () => {
const service = new MysqlIntrospectionService();
service.mysqlPoolManager = {
getPool: jest.fn(),
} as any;
await expect(service.sampleTable(makeSource({
extra: {
allowedTables: ['customers'],
blockedTables: [],
schemaVisibility: 'all-names-only',
},
}), {
table: 'orders',
})).rejects.toThrow('table_not_allowed');
});
});
describe('MysqlIntrospectionService listSchema visibility', () => {
it('returns names only for requested unblocked tables outside allowedTables when schemaVisibility is all-names-only', async () => {
const query = jest.fn()
.mockResolvedValueOnce([
[
{ TABLE_NAME: 'customers', TABLE_COMMENT: 'Allowed customers' },
{ TABLE_NAME: 'orders', TABLE_COMMENT: 'Visible name only' },
],
])
.mockResolvedValueOnce([
[
{
TABLE_NAME: 'customers',
TABLE_COMMENT: 'Allowed customers',
COLUMN_NAME: 'id',
ORDINAL_POSITION: 1,
COLUMN_TYPE: 'int',
DATA_TYPE: 'int',
IS_NULLABLE: 'NO',
COLUMN_DEFAULT: null,
COLUMN_COMMENT: '',
COLUMN_KEY: 'PRI',
},
],
])
.mockResolvedValueOnce([[{ TABLE_NAME: 'customers', INDEX_NAME: 'PRIMARY', NON_UNIQUE: 0, SEQ_IN_INDEX: 1, COLUMN_NAME: 'id' }]])
.mockResolvedValueOnce([[]]);
const service = new MysqlIntrospectionService();
service.mysqlPoolManager = {
getPool: jest.fn().mockResolvedValue({ query }),
} as any;
const schema = await service.listSchema(makeSource({
extra: {
allowedTables: ['customers'],
blockedTables: ['payments'],
schemaVisibility: 'all-names-only',
},
}), { tables: ['customers', 'orders', 'payments'] });
expect(schema.tables).toEqual([
{
name: 'customers',
comment: 'Allowed customers',
columns: [
expect.objectContaining({ name: 'id' }),
],
primaryKey: ['id'],
indexes: [{ name: 'PRIMARY', unique: true, columns: ['id'] }],
foreignKeys: [],
},
{
name: 'orders',
comment: 'Visible name only',
columns: [],
primaryKey: [],
indexes: [],
foreignKeys: [],
},
]);
expect(query).toHaveBeenNthCalledWith(
1,
expect.stringContaining('information_schema.TABLES'),
['shop', 'customers', 'orders']
);
expect(query).toHaveBeenNthCalledWith(
2,
expect.stringContaining('information_schema.COLUMNS'),
['shop', 'customers']
);
});
it('does not request TABLE_COMMENT from information_schema.COLUMNS', async () => {
const query = jest.fn()
.mockResolvedValueOnce([
[
{ TABLE_NAME: 'customers', TABLE_COMMENT: 'Allowed customers' },
],
])
.mockResolvedValueOnce([
[
{
TABLE_NAME: 'customers',
COLUMN_NAME: 'id',
ORDINAL_POSITION: 1,
COLUMN_TYPE: 'int',
DATA_TYPE: 'int',
IS_NULLABLE: 'NO',
COLUMN_DEFAULT: null,
COLUMN_COMMENT: '',
COLUMN_KEY: 'PRI',
},
],
])
.mockResolvedValueOnce([[{ TABLE_NAME: 'customers', INDEX_NAME: 'PRIMARY', NON_UNIQUE: 0, SEQ_IN_INDEX: 1, COLUMN_NAME: 'id' }]])
.mockResolvedValueOnce([[]]);
const service = new MysqlIntrospectionService();
service.mysqlPoolManager = {
getPool: jest.fn().mockResolvedValue({ query }),
} as any;
const schema = await service.listSchema(makeSource());
expect(query).toHaveBeenNthCalledWith(
1,
expect.stringContaining('information_schema.TABLES'),
['shop', 'customers']
);
expect(query).toHaveBeenNthCalledWith(
2,
expect.not.stringContaining('TABLE_COMMENT'),
['shop', 'customers']
);
expect(schema.tables[0].comment).toBe('Allowed customers');
});
});