fix: 闭环QQBot消息推送授权校验
This commit is contained in:
parent
7ade7d6435
commit
d0c4f29211
@ -647,3 +647,22 @@ INSERT INTO admin_menu (id, pid, name, path, component, redirect, auth_code, typ
|
||||
(2041700000000120471,2041700000000100414,'QqBotMessageTemplateList',NULL,NULL,NULL,'QqBot:MessageTemplate:List','button','{"title":"common.list"}',1,0),(2041700000000120472,2041700000000100414,'QqBotMessageTemplateCreate',NULL,NULL,NULL,'QqBot:MessageTemplate:Create','button','{"title":"common.create"}',1,0),(2041700000000120473,2041700000000100414,'QqBotMessageTemplateUpdate',NULL,NULL,NULL,'QqBot:MessageTemplate:Update','button','{"title":"common.edit"}',1,0),(2041700000000120474,2041700000000100414,'QqBotMessageTemplateDelete',NULL,NULL,NULL,'QqBot:MessageTemplate:Delete','button','{"title":"common.delete"}',1,0),(2041700000000120475,2041700000000100414,'QqBotMessageTemplateToggle',NULL,NULL,NULL,'QqBot:MessageTemplate:Toggle','button','{"title":"启停"}',1,0),(2041700000000120476,2041700000000100414,'QqBotMessageTemplatePreview',NULL,NULL,NULL,'QqBot:MessageTemplate:Preview','button','{"title":"预览"}',1,0),
|
||||
(2041700000000120481,2041700000000100410,'QqBotAccountMessagePushList',NULL,NULL,NULL,'QqBot:Account:MessagePush:List','button','{"title":"common.list"}',1,0),(2041700000000120482,2041700000000100410,'QqBotAccountMessagePushCreate',NULL,NULL,NULL,'QqBot:Account:MessagePush:Create','button','{"title":"common.create"}',1,0),(2041700000000120483,2041700000000100410,'QqBotAccountMessagePushUpdate',NULL,NULL,NULL,'QqBot:Account:MessagePush:Update','button','{"title":"common.edit"}',1,0),(2041700000000120484,2041700000000100410,'QqBotAccountMessagePushDelete',NULL,NULL,NULL,'QqBot:Account:MessagePush:Delete','button','{"title":"common.delete"}',1,0),(2041700000000120485,2041700000000100410,'QqBotAccountMessagePushToggle',NULL,NULL,NULL,'QqBot:Account:MessagePush:Toggle','button','{"title":"启停"}',1,0)
|
||||
ON DUPLICATE KEY UPDATE pid = VALUES(pid), path = VALUES(path), component = VALUES(component), redirect = VALUES(redirect), auth_code = VALUES(auth_code), type = VALUES(type), meta = VALUES(meta), status = VALUES(status), sort = VALUES(sort), is_deleted = 0;
|
||||
|
||||
INSERT IGNORE INTO admin_role_menu (role_id, menu_id)
|
||||
SELECT role.id, menu.id
|
||||
FROM admin_role role
|
||||
CROSS JOIN admin_menu menu
|
||||
WHERE role.role_code IN ('super', 'admin')
|
||||
AND role.status = 1
|
||||
AND role.is_deleted = 0
|
||||
AND menu.id IN (
|
||||
2041700000000100413, 2041700000000100414,
|
||||
2041700000000120461, 2041700000000120462, 2041700000000120463,
|
||||
2041700000000120464, 2041700000000120465, 2041700000000120471,
|
||||
2041700000000120472, 2041700000000120473, 2041700000000120474,
|
||||
2041700000000120475, 2041700000000120476, 2041700000000120481,
|
||||
2041700000000120482, 2041700000000120483, 2041700000000120484,
|
||||
2041700000000120485
|
||||
)
|
||||
AND menu.status = 1
|
||||
AND menu.is_deleted = 0;
|
||||
|
||||
@ -66,8 +66,8 @@ SELECT 'seed_qqbot_message_push_menu_mismatch' AS check_name,
|
||||
FROM expected_menu expected
|
||||
LEFT JOIN admin_menu actual ON actual.id = expected.id
|
||||
WHERE actual.id IS NULL
|
||||
OR actual.name <> expected.name
|
||||
OR NOT (actual.auth_code <=> expected.auth_code)
|
||||
OR NOT (BINARY actual.name <=> BINARY expected.name)
|
||||
OR NOT (BINARY actual.auth_code <=> BINARY expected.auth_code)
|
||||
OR actual.pid <> expected.pid
|
||||
OR actual.sort <> expected.sort
|
||||
OR actual.status <> 1
|
||||
|
||||
@ -117,6 +117,28 @@ const menuEntries: readonly MenuEntry[] = [
|
||||
['2041700000000120485', '2041700000000100410', 'QqBotAccountMessagePushToggle', 'QqBot:Account:MessagePush:Toggle', '0'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Advances SQL single-quote state at one character, respecting SQL doubled
|
||||
* quotes and MySQL backslash escaping. An odd number of preceding backslashes
|
||||
* escapes a quote; an even number lets it open or close a string.
|
||||
*
|
||||
* @param sql SQL source being scanned.
|
||||
* @param index Index of the current character in {@link sql}.
|
||||
* @param quoted Whether the scanner is currently inside a single-quoted value.
|
||||
* @returns The next quote state and whether the following doubled quote is consumed.
|
||||
*/
|
||||
const advanceSqlQuoteState = (sql: string, index: number, quoted: boolean) => {
|
||||
if (sql[index] !== "'") return { quoted, skipNext: false };
|
||||
if (quoted && sql[index + 1] === "'") return { quoted, skipNext: true };
|
||||
|
||||
let precedingBackslashes = 0;
|
||||
for (let cursor = index - 1; sql[cursor] === '\\'; cursor -= 1) precedingBackslashes += 1;
|
||||
return {
|
||||
quoted: precedingBackslashes % 2 === 1 ? quoted : !quoted,
|
||||
skipNext: false,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes SQL syntax while preserving the exact casing and whitespace inside
|
||||
* single-quoted contract values.
|
||||
@ -130,17 +152,17 @@ const normalizeSql = (sql: string) => {
|
||||
const character = sql[index];
|
||||
if (quoted) {
|
||||
normalized += character;
|
||||
if (character === "'" && sql[index + 1] === "'") {
|
||||
const quoteState = advanceSqlQuoteState(sql, index, quoted);
|
||||
if (quoteState.skipNext) {
|
||||
normalized += sql[index + 1];
|
||||
index += 1;
|
||||
} else if (character === "'" && sql[index - 1] !== '\\') {
|
||||
quoted = false;
|
||||
}
|
||||
quoted = quoteState.quoted;
|
||||
previousWasWhitespace = false;
|
||||
continue;
|
||||
}
|
||||
if (character === "'") {
|
||||
quoted = true;
|
||||
quoted = advanceSqlQuoteState(sql, index, quoted).quoted;
|
||||
normalized += character;
|
||||
previousWasWhitespace = false;
|
||||
continue;
|
||||
@ -180,12 +202,13 @@ const splitSqlTuple = (tuple: string) => {
|
||||
let quoted = false;
|
||||
for (let index = 0; index < tuple.length; index += 1) {
|
||||
const character = tuple[index];
|
||||
if (character === "'" && tuple[index + 1] === "'") {
|
||||
const quoteState = advanceSqlQuoteState(tuple, index, quoted);
|
||||
if (quoteState.skipNext) {
|
||||
current += tuple[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (character === "'" && tuple[index - 1] !== '\\') quoted = !quoted;
|
||||
quoted = quoteState.quoted;
|
||||
if (character === ',' && !quoted) {
|
||||
values.push(current.trim());
|
||||
current = '';
|
||||
@ -208,12 +231,13 @@ const extractSqlTuples = (values: string) => {
|
||||
let quoted = false;
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
const character = values[index];
|
||||
if (character === "'" && values[index + 1] === "'") {
|
||||
const quoteState = advanceSqlQuoteState(values, index, quoted);
|
||||
if (quoteState.skipNext) {
|
||||
current += values[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (character === "'" && values[index - 1] !== '\\') quoted = !quoted;
|
||||
quoted = quoteState.quoted;
|
||||
if (!quoted && character === '(') {
|
||||
depth += 1;
|
||||
if (depth === 1) current = '';
|
||||
@ -247,6 +271,24 @@ const extractMessagePushMenuRows = (sql: string) => {
|
||||
] as MenuEntry);
|
||||
};
|
||||
|
||||
/** Extracts the statement that grants the seeded message-push menus to active roles. */
|
||||
const extractMessagePushMenuRoleGrantStatement = (sql: string) => {
|
||||
const statements = splitSqlStatements(sql);
|
||||
const menuSeedIndex = statements.findIndex((statement) =>
|
||||
statement.startsWith('insert into admin_menu') && statement.includes(menuEntries[0][0]));
|
||||
expect(menuSeedIndex).toBeGreaterThanOrEqual(0);
|
||||
const roleGrantIndex = statements.findIndex((statement, index) =>
|
||||
index > menuSeedIndex && statement.startsWith('insert ignore into admin_role_menu'));
|
||||
expect(roleGrantIndex).toBeGreaterThan(menuSeedIndex);
|
||||
return statements[roleGrantIndex] || '';
|
||||
};
|
||||
|
||||
/** Extracts the explicit stable menu IDs from a menu-role grant statement. */
|
||||
const extractMenuRoleGrantIds = (statement: string) => {
|
||||
const ids = statement.match(/menu\.id in \(([^)]*)\)/)?.[1].match(/\d+/g) || [];
|
||||
return ids;
|
||||
};
|
||||
|
||||
/** Splits SQL statements only at semicolons outside single-quoted values. */
|
||||
const splitSqlStatements = (sql: string) => {
|
||||
const statements: string[] = [];
|
||||
@ -256,12 +298,13 @@ const splitSqlStatements = (sql: string) => {
|
||||
for (let index = 0; index < sql.length; index += 1) {
|
||||
const character = sql[index];
|
||||
current += character;
|
||||
if (quoted && character === "'" && sql[index + 1] === "'") {
|
||||
const quoteState = advanceSqlQuoteState(sql, index, quoted);
|
||||
if (quoteState.skipNext) {
|
||||
current += sql[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (character === "'" && sql[index - 1] !== '\\') quoted = !quoted;
|
||||
quoted = quoteState.quoted;
|
||||
if (character === ';' && !quoted) {
|
||||
statements.push(current.trim());
|
||||
current = '';
|
||||
@ -287,11 +330,12 @@ const extractExpectedMenuCte = (statement: string) => {
|
||||
let quoted = false;
|
||||
for (let index = openIndex; index < statement.length; index += 1) {
|
||||
const character = statement[index];
|
||||
if (quoted && character === "'" && statement[index + 1] === "'") {
|
||||
const quoteState = advanceSqlQuoteState(statement, index, quoted);
|
||||
if (quoteState.skipNext) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (character === "'" && statement[index - 1] !== '\\') quoted = !quoted;
|
||||
quoted = quoteState.quoted;
|
||||
if (quoted) continue;
|
||||
if (character === '(') depth += 1;
|
||||
if (character === ')') {
|
||||
@ -325,6 +369,18 @@ describe('QQBot message-push SQL contract', () => {
|
||||
.toBe("select auth_code, 'QqBot:MessageTemplate:List', '${{endpoint}}'");
|
||||
});
|
||||
|
||||
it('recognizes SQL quote endings, doubled quotes, and odd or even backslashes in every scanner', () => {
|
||||
expect(splitSqlTuple("'closed', next")).toEqual(["'closed'", 'next']);
|
||||
expect(splitSqlTuple("'it''s closed', next")).toEqual(["'it's closed'", 'next']);
|
||||
expect(splitSqlTuple(String.raw`'odd\' quote', next`)).toEqual([String.raw`'odd\' quote'`, 'next']);
|
||||
expect(splitSqlTuple(String.raw`'even\\', next`)).toEqual([String.raw`'even\\'`, 'next']);
|
||||
expect(extractSqlTuples(String.raw`('even\\'), ('next')`)).toEqual([String.raw`'even\\'`, "'next'"]);
|
||||
expect(splitSqlStatements(String.raw`select 'even\\'; select 'next';`)).toEqual([
|
||||
String.raw`select 'even\\';`,
|
||||
"select 'next';",
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps each table DDL and index tuple exact in current and refactor schemas', () => {
|
||||
for (const [table, expected] of Object.entries(messagePushTableDefinitions)) {
|
||||
for (const sql of [bootstrapSql, schemaSql]) {
|
||||
@ -374,12 +430,23 @@ describe('QQBot message-push SQL contract', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('grants only the seeded message-push menus to active super and admin roles after refactor seeding', () => {
|
||||
const roleGrant = extractMessagePushMenuRoleGrantStatement(seedSql);
|
||||
expect(roleGrant).toContain('insert ignore into admin_role_menu (role_id, menu_id)');
|
||||
expect(extractMenuRoleGrantIds(roleGrant)).toEqual(menuEntries.map(([id]) => id));
|
||||
expect(roleGrant).toContain("role.role_code in ('super', 'admin')");
|
||||
expect(roleGrant).toContain('role.status = 1');
|
||||
expect(roleGrant).toContain('role.is_deleted = 0');
|
||||
expect(roleGrant).toContain('menu.status = 1');
|
||||
expect(roleGrant).toContain('menu.is_deleted = 0');
|
||||
});
|
||||
|
||||
it('uses an exact mismatch CTE and null-safe predicates for every stable menu tuple', () => {
|
||||
const mismatch = extractVerificationStatement(verifySql, 'seed_qqbot_message_push_menu_mismatch');
|
||||
expect(extractMismatchMenuRows(mismatch)).toEqual(menuEntries);
|
||||
expect(mismatch).toContain('left join admin_menu actual on actual.id = expected.id');
|
||||
expect(mismatch).toContain('actual.name <> expected.name');
|
||||
expect(mismatch).toContain('not (actual.auth_code <=> expected.auth_code)');
|
||||
expect(mismatch).toContain('not (binary actual.name <=> binary expected.name)');
|
||||
expect(mismatch).toContain('not (binary actual.auth_code <=> binary expected.auth_code)');
|
||||
expect(mismatch).toContain('actual.pid <> expected.pid');
|
||||
expect(mismatch).toContain('actual.sort <> expected.sort');
|
||||
expect(mismatch).toContain('actual.status <> 1');
|
||||
@ -405,4 +472,15 @@ describe('QQBot message-push SQL contract', () => {
|
||||
expect(roleGrant).toContain('role.is_deleted = 0');
|
||||
expect(roleGrant).toContain('role_menu.menu_id is null');
|
||||
});
|
||||
|
||||
it('extracts the three message-push verification CTEs as separate statements', () => {
|
||||
const checkNames = splitSqlStatements(verifySql)
|
||||
.filter((statement) => statement.includes('seed_qqbot_message_push_menu_'))
|
||||
.map((statement) => statement.match(/'([^']+)' as check_name/)?.[1]);
|
||||
expect(checkNames).toEqual([
|
||||
'seed_qqbot_message_push_menu_mismatch',
|
||||
'seed_qqbot_message_push_menu_cardinality',
|
||||
'seed_qqbot_message_push_menu_role_grant_missing',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user