diff --git a/sql/refactor-v3/01-seed-core.sql b/sql/refactor-v3/01-seed-core.sql index de5882a..06a3036 100644 --- a/sql/refactor-v3/01-seed-core.sql +++ b/sql/refactor-v3/01-seed-core.sql @@ -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; diff --git a/sql/refactor-v3/99-verify.sql b/sql/refactor-v3/99-verify.sql index d8ef97c..2d4ef2d 100644 --- a/sql/refactor-v3/99-verify.sql +++ b/sql/refactor-v3/99-verify.sql @@ -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 diff --git a/test/modules/qqbot/message-push/qqbot-message-push-sql.spec.ts b/test/modules/qqbot/message-push/qqbot-message-push-sql.spec.ts index de34b23..785ad7f 100644 --- a/test/modules/qqbot/message-push/qqbot-message-push-sql.spec.ts +++ b/test/modules/qqbot/message-push/qqbot-message-push-sql.spec.ts @@ -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', + ]); + }); });