fix(GOV-012): 修复目录元数据校验与查询错误脱敏
This commit is contained in:
@@ -6,6 +6,8 @@ import mysql from 'mysql2/promise';
|
|||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
const mysql2Version = require('mysql2/package.json').version;
|
const mysql2Version = require('mysql2/package.json').version;
|
||||||
const DEVELOPMENT_DATABASE = 'a_share_analysis_dev';
|
const DEVELOPMENT_DATABASE = 'a_share_analysis_dev';
|
||||||
|
const VALID_TRIGGER_TIMINGS = new Set(['BEFORE', 'AFTER']);
|
||||||
|
const VALID_TRIGGER_EVENTS = new Set(['INSERT', 'UPDATE', 'DELETE']);
|
||||||
|
|
||||||
const CATALOG_QUERIES = Object.freeze({
|
const CATALOG_QUERIES = Object.freeze({
|
||||||
tables: `SELECT TABLE_NAME, TABLE_TYPE
|
tables: `SELECT TABLE_NAME, TABLE_TYPE
|
||||||
@@ -127,11 +129,17 @@ function requireCatalogText(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function queryCatalogRows(connection, sql, database, limits) {
|
async function queryCatalogRows(connection, sql, database, limits) {
|
||||||
const result = await connection.query({
|
let result;
|
||||||
sql,
|
try {
|
||||||
values: [database],
|
result = await connection.query({
|
||||||
timeout: limits.queryTimeoutMs,
|
sql,
|
||||||
});
|
values: [database],
|
||||||
|
timeout: limits.queryTimeoutMs,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// mysql2 错误可能包含连接 URL、用户名或密码,目录读取的公共接口一律不透传原始文本。
|
||||||
|
throw new SchemaExportError('DATABASE_QUERY_FAILED', '开发库 Schema 查询失败');
|
||||||
|
}
|
||||||
if (!Array.isArray(result) || !Array.isArray(result[0])) {
|
if (!Array.isArray(result) || !Array.isArray(result[0])) {
|
||||||
throw new SchemaExportError('CATALOG_SHAPE_INVALID', 'Schema 对象目录查询结果无效');
|
throw new SchemaExportError('CATALOG_SHAPE_INVALID', 'Schema 对象目录查询结果无效');
|
||||||
}
|
}
|
||||||
@@ -189,15 +197,25 @@ export async function readSchemaCatalog(connection, database, limits = DEFAULT_E
|
|||||||
|
|
||||||
const triggerRows = await queryCatalogRows(connection, CATALOG_QUERIES.triggers, database, limits);
|
const triggerRows = await queryCatalogRows(connection, CATALOG_QUERIES.triggers, database, limits);
|
||||||
const triggers = triggerRows.map((row) => {
|
const triggers = triggerRows.map((row) => {
|
||||||
const order = Number(row.ACTION_ORDER);
|
const timing = requireCatalogText(row.ACTION_TIMING);
|
||||||
if (!Number.isInteger(order) || order < 0) {
|
const event = requireCatalogText(row.EVENT_MANIPULATION);
|
||||||
|
const rawOrder = row.ACTION_ORDER;
|
||||||
|
const order = Number(rawOrder);
|
||||||
|
if (
|
||||||
|
!VALID_TRIGGER_TIMINGS.has(timing) ||
|
||||||
|
!VALID_TRIGGER_EVENTS.has(event) ||
|
||||||
|
(typeof rawOrder !== 'number' && typeof rawOrder !== 'string') ||
|
||||||
|
(typeof rawOrder === 'string' && rawOrder.trim().length === 0) ||
|
||||||
|
!Number.isInteger(order) ||
|
||||||
|
order < 1
|
||||||
|
) {
|
||||||
throw new SchemaExportError('CATALOG_SHAPE_INVALID', 'Schema 触发器执行顺序无效');
|
throw new SchemaExportError('CATALOG_SHAPE_INVALID', 'Schema 触发器执行顺序无效');
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
name: requireCatalogText(row.TRIGGER_NAME),
|
name: requireCatalogText(row.TRIGGER_NAME),
|
||||||
table: requireCatalogText(row.EVENT_OBJECT_TABLE),
|
table: requireCatalogText(row.EVENT_OBJECT_TABLE),
|
||||||
timing: requireCatalogText(row.ACTION_TIMING),
|
timing,
|
||||||
event: requireCatalogText(row.EVENT_MANIPULATION),
|
event,
|
||||||
order,
|
order,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -232,6 +232,63 @@ test('目录拒绝未知表类型和超过对象上限', async () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('目录拒绝非法触发器时机、事件和空执行顺序', async () => {
|
||||||
|
for (const trigger of [
|
||||||
|
{
|
||||||
|
TRIGGER_NAME: 'invalid_timing',
|
||||||
|
EVENT_OBJECT_TABLE: 'market_snapshot',
|
||||||
|
ACTION_TIMING: 'DURING',
|
||||||
|
EVENT_MANIPULATION: 'INSERT',
|
||||||
|
ACTION_ORDER: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
TRIGGER_NAME: 'invalid_event',
|
||||||
|
EVENT_OBJECT_TABLE: 'market_snapshot',
|
||||||
|
ACTION_TIMING: 'BEFORE',
|
||||||
|
EVENT_MANIPULATION: 'MERGE',
|
||||||
|
ACTION_ORDER: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
TRIGGER_NAME: 'empty_order',
|
||||||
|
EVENT_OBJECT_TABLE: 'market_snapshot',
|
||||||
|
ACTION_TIMING: 'BEFORE',
|
||||||
|
EVENT_MANIPULATION: 'INSERT',
|
||||||
|
ACTION_ORDER: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
TRIGGER_NAME: 'missing_order',
|
||||||
|
EVENT_OBJECT_TABLE: 'market_snapshot',
|
||||||
|
ACTION_TIMING: 'BEFORE',
|
||||||
|
EVENT_MANIPULATION: 'INSERT',
|
||||||
|
ACTION_ORDER: null,
|
||||||
|
},
|
||||||
|
]) {
|
||||||
|
const connection = createCatalogConnection({
|
||||||
|
'information_schema.TRIGGERS': [trigger],
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
() => readSchemaCatalog(connection, 'a_share_analysis_dev', DEFAULT_EXPORT_LIMITS),
|
||||||
|
(error) => error instanceof SchemaExportError && error.code === 'CATALOG_SHAPE_INVALID',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('直接目录读取将数据库异常转为固定脱敏错误', async () => {
|
||||||
|
const connection = {
|
||||||
|
async query() {
|
||||||
|
throw new Error('connect mysql://schema_reader:not-for-logs@127.0.0.1:3306/a_share_analysis_dev');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => readSchemaCatalog(connection, 'a_share_analysis_dev', DEFAULT_EXPORT_LIMITS),
|
||||||
|
(error) =>
|
||||||
|
error instanceof SchemaExportError &&
|
||||||
|
error.code === 'DATABASE_QUERY_FAILED' &&
|
||||||
|
error.message === '开发库 Schema 查询失败',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const completeCatalog = Object.freeze({
|
const completeCatalog = Object.freeze({
|
||||||
tables: ['market_snapshot'],
|
tables: ['market_snapshot'],
|
||||||
views: ['market_dashboard', 'market_summary'],
|
views: ['market_dashboard', 'market_summary'],
|
||||||
|
|||||||
Reference in New Issue
Block a user