分离5.7与8.0声明头和注释编码,增加无损往返校验及中文错误上下文,补充五种异常元数据拒绝测试。 最终Windows MySQL 5.7.44与8.0.45完整专项分别通过110、121个叶子用例,零失败零跳过。后端常规回归、静态检查、模块校验及Linux测试包交叉编译通过;32个常规条件测试跳过,不作为通过证据。 同步七份任务、支持矩阵及运维文档;P0-05保持进行中,全部44项范围不变。本批不代表Linux实库、其余数据库、前端或生产验收通过。
304 lines
13 KiB
Go
304 lines
13 KiB
Go
package cli
|
||
|
||
import (
|
||
"database/sql"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/go-sql-driver/mysql"
|
||
|
||
"git.pcux.cn/xuans/Scert/backend/internal/database"
|
||
"git.pcux.cn/xuans/Scert/backend/internal/testsupport/mysqltest"
|
||
)
|
||
|
||
func TestMySQLRealNativeRoutines(t *testing.T) {
|
||
server := mysqltest.New(t)
|
||
source := server.Database(t)
|
||
parsed, err := mysql.ParseDSN(source.DSN)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
grantMySQLProgramAccess(t, server, parsed.User, parsed.DBName)
|
||
// 只调整独立实例专用账户,不能修改共享服务的函数信任开关。
|
||
account := mysqlStringLiteral(parsed.User) + "@'127.0.0.1'"
|
||
grant := "GRANT SELECT ON mysql.proc TO " + account
|
||
if server.IsMySQL8() {
|
||
grant = "GRANT SHOW_ROUTINE, SUPER ON *.* TO " + account
|
||
}
|
||
if _, err := server.Admin().ExecContext(t.Context(), grant); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
db, err := database.Open(t.Context(), source)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer db.Close()
|
||
connection, err := db.DB.Conn(t.Context())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer connection.Close()
|
||
for _, statement := range []string{
|
||
"SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION'",
|
||
"SET NAMES utf8mb4 COLLATE utf8mb4_bin",
|
||
"CREATE TABLE interop (id INT PRIMARY KEY, value INT NOT NULL, label VARCHAR(100)) ENGINE=InnoDB",
|
||
"INSERT INTO interop VALUES (1, 10, '原始值')",
|
||
"CREATE FUNCTION format_value(input_value DECIMAL(20,4)) RETURNS VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DETERMINISTIC NO SQL SQL SECURITY INVOKER COMMENT '函数说明' RETURN CONCAT('函数;', input_value)",
|
||
"CREATE PROCEDURE `change``value`(IN target_id INT, INOUT step_value INT, OUT result_label VARCHAR(100)) SQL SECURITY INVOKER COMMENT '过程说明' BEGIN UPDATE interop SET value = value + step_value WHERE id = target_id; SET step_value = step_value + 1; SET result_label = format_value(step_value); END",
|
||
"CREATE TRIGGER routine_trigger BEFORE INSERT ON interop FOR EACH ROW SET NEW.label = format_value(NEW.value)",
|
||
"ALTER DATABASE COLLATE latin1_swedish_ci",
|
||
"SET NAMES latin1 COLLATE latin1_bin",
|
||
"CREATE FUNCTION `legacy_caf" + string([]byte{0xe9}) + "`() RETURNS VARCHAR(100) DETERMINISTIC NO SQL COMMENT 'caf" + string([]byte{0xe9}) + ";;' RETURN 'caf" + string([]byte{0xe9}) + ";;'",
|
||
"SET NAMES utf8mb4 COLLATE utf8mb4_bin",
|
||
"ALTER DATABASE COLLATE utf8mb4_unicode_ci",
|
||
} {
|
||
if _, err := connection.ExecContext(t.Context(), statement); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
definition := readMySQLRoutineDefinitions(t, db.DB)
|
||
t.Run("metadata_rejection", func(t *testing.T) {
|
||
checkMySQLRoutineMetadataRejection(t, db.DB, server.IsMySQL8())
|
||
})
|
||
output := filepath.Join(server.Root(), "native-routines.sql")
|
||
if err := backupMySQLDatabase(t.Context(), source, output); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, direction := range []string{"scert_to_scert", "scert_to_official"} {
|
||
t.Run(direction, func(t *testing.T) {
|
||
target := server.Database(t)
|
||
targetDSN, err := mysql.ParseDSN(target.DSN)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
grantMySQLProgramAccess(t, server, parsed.User, targetDSN.DBName)
|
||
targetDSN.User = parsed.User
|
||
target.DSN = targetDSN.FormatDSN()
|
||
verify, err := database.Open(t.Context(), target)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer verify.Close()
|
||
// 恢复到已有同名例程的数据库,不能只验证空库创建。
|
||
if _, err := verify.DB.ExecContext(t.Context(), "CREATE FUNCTION format_value(input_value INT) RETURNS INT DETERMINISTIC NO SQL RETURN -1"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for attempt := 0; attempt < 2; attempt++ {
|
||
if direction == "scert_to_official" {
|
||
server.Import(t, target, output)
|
||
} else if err := restoreMySQL(t.Context(), target, output); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if restored := readMySQLRoutineDefinitions(t, verify.DB); restored != definition {
|
||
t.Fatalf("例程定义、参数、权限上下文或字符集不一致:\n源:%s\n恢复:%s", definition, restored)
|
||
}
|
||
checkMySQLInteropValue(t, target, "SELECT CONCAT(value, '|', label) FROM interop WHERE id = 1", "10|原始值")
|
||
checkMySQLInteropValue(t, target, "SELECT format_value(7)", "函数;7.0000")
|
||
checkMySQLInteropValue(t, target, "SELECT `legacy_café`()", "café;;")
|
||
checkMySQLInteropValue(t, target, "SELECT @@collation_database", "utf8mb4_unicode_ci")
|
||
conn, err := verify.DB.Conn(t.Context())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, statement := range []string{"SET @step = 5", "CALL `change``value`(1, @step, @label)", "INSERT INTO interop VALUES (2, 4, '新行')"} {
|
||
if _, err := conn.ExecContext(t.Context(), statement); err != nil {
|
||
_ = conn.Close()
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
var actual string
|
||
err = conn.QueryRowContext(t.Context(), "SELECT CONCAT(@step, '|', @label)").Scan(&actual)
|
||
_ = conn.Close()
|
||
if err != nil || actual != "6|函数;6.0000" {
|
||
t.Fatalf("过程输入输出参数错误:%q,%v", actual, err)
|
||
}
|
||
checkMySQLInteropValue(t, target, "SELECT CAST(value AS CHAR) FROM interop WHERE id = 1", "15")
|
||
checkMySQLInteropValue(t, target, "SELECT label FROM interop WHERE id = 2", "函数;4.0000")
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestMySQLRealRoutineVisibility(t *testing.T) {
|
||
server := mysqltest.New(t)
|
||
scenarios := []string{"visible", "hidden", "empty_schema_without_permission", "global_select", "execute_only"}
|
||
if server.IsMySQL8() {
|
||
scenarios = append(scenarios, "active_role", "nested_role", "inactive_role", "partial_revoke", "partial_revoke_with_show")
|
||
} else {
|
||
scenarios = append(scenarios, "column_only")
|
||
}
|
||
for _, scenario := range scenarios {
|
||
t.Run(scenario, func(t *testing.T) {
|
||
cfg := server.Database(t)
|
||
parsed, err := mysql.ParseDSN(cfg.DSN)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
account := mysqlStringLiteral(parsed.User) + "@'127.0.0.1'"
|
||
permission := "SELECT ON mysql.proc"
|
||
if server.IsMySQL8() {
|
||
permission = "SHOW_ROUTINE ON *.*"
|
||
}
|
||
if _, err := server.Admin().ExecContext(t.Context(), "GRANT "+permission+" TO "+account); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if scenario != "empty_schema_without_permission" {
|
||
statement := "CREATE PROCEDURE " + quoteMySQLIdentifier(parsed.DBName) + ".hidden_routine() SELECT 123"
|
||
if _, err := server.Admin().ExecContext(t.Context(), statement); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
allowed := scenario == "visible" || scenario == "global_select" || scenario == "active_role" || scenario == "nested_role" || scenario == "partial_revoke_with_show"
|
||
if scenario != "visible" && scenario != "partial_revoke_with_show" {
|
||
if _, err := server.Admin().ExecContext(t.Context(), "REVOKE "+permission+" FROM "+account); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
var statements []string
|
||
switch scenario {
|
||
case "global_select":
|
||
statements = append(statements, "GRANT SELECT ON *.* TO "+account)
|
||
case "execute_only":
|
||
statements = append(statements, "GRANT EXECUTE ON "+quoteMySQLIdentifier(parsed.DBName)+".* TO "+account)
|
||
case "column_only":
|
||
statements = append(statements, "GRANT SELECT (db, name, type) ON mysql.proc TO "+account)
|
||
case "active_role", "nested_role", "inactive_role":
|
||
role := quoteMySQLIdentifier("routine_"+scenario) + "@'%'"
|
||
statements = append(statements, "CREATE ROLE "+role, "GRANT SHOW_ROUTINE ON *.* TO "+role, "GRANT "+role+" TO "+account)
|
||
if scenario == "active_role" {
|
||
statements = append(statements, "SET DEFAULT ROLE "+role+" TO "+account)
|
||
}
|
||
if scenario == "nested_role" {
|
||
parent := "'routine_parent'@'%'"
|
||
statements = append(statements, "REVOKE "+role+" FROM "+account, "CREATE ROLE "+parent, "GRANT "+role+" TO "+parent, "GRANT "+parent+" TO "+account, "SET DEFAULT ROLE "+parent+" TO "+account)
|
||
}
|
||
case "partial_revoke", "partial_revoke_with_show":
|
||
// 先移除夹具的库级授权,否则一次 REVOKE 只移除该授权而不撤销全局权限。
|
||
statements = append(statements, "REVOKE SELECT ON "+quoteMySQLIdentifier(parsed.DBName)+".* FROM "+account,
|
||
"SET GLOBAL partial_revokes = ON", "GRANT SELECT ON *.* TO "+account, "REVOKE SELECT ON "+quoteMySQLIdentifier(parsed.DBName)+".* FROM "+account)
|
||
}
|
||
for _, statement := range statements {
|
||
if _, err := server.Admin().ExecContext(t.Context(), statement); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
if scenario == "hidden" || scenario == "empty_schema_without_permission" || scenario == "inactive_role" {
|
||
checkMySQLInteropValue(t, cfg, "SELECT CAST(COUNT(*) AS CHAR) FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = DATABASE()", "0")
|
||
}
|
||
if allowed || scenario == "execute_only" {
|
||
checkMySQLInteropValue(t, cfg, "SELECT CAST(COUNT(*) AS CHAR) FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = DATABASE()", "1")
|
||
}
|
||
if scenario == "execute_only" {
|
||
checkMySQLInteropValue(t, cfg, "SELECT CAST(ROUTINE_DEFINITION IS NULL AS CHAR) FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = DATABASE()", "1")
|
||
}
|
||
output := filepath.Join(server.Root(), "routines-"+scenario+".sql")
|
||
if err := os.WriteFile(output, []byte("existing backup"), 0o600); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
err = backupMySQLDatabase(t.Context(), cfg, output)
|
||
if allowed {
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
data, err := os.ReadFile(output)
|
||
if err != nil || !strings.Contains(string(data), "hidden_routine") {
|
||
t.Fatal("可见例程未完整导出")
|
||
}
|
||
} else {
|
||
if err == nil || !strings.Contains(err.Error(), "例程") {
|
||
t.Fatalf("缺少完整例程可见权限必须拒绝:%v", err)
|
||
}
|
||
data, readErr := os.ReadFile(output)
|
||
if readErr != nil || string(data) != "existing backup" {
|
||
t.Fatal("权限不足破坏了原有备份")
|
||
}
|
||
if _, err := os.Stat(output + ".tmp"); !os.IsNotExist(err) {
|
||
t.Fatal("权限不足遗留临时文件")
|
||
}
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func checkMySQLRoutineMetadataRejection(t *testing.T, db *sql.DB, mysql8 bool) {
|
||
t.Helper()
|
||
tx, err := db.BeginTx(t.Context(), nil)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer tx.Rollback()
|
||
routine := mysqlRoutine{name: "legacy_café", kind: "FUNCTION", utf8MetadataHeader: mysql8}
|
||
const column = "Create Function"
|
||
for _, item := range []struct {
|
||
name string
|
||
alter func(string) string
|
||
message string
|
||
}{
|
||
{"incomplete_header", func(string) string { return "CREATE FUNCTION" }, "参数列表"},
|
||
{"body_mismatch", func(value string) string { return value + " " }, "正文与目录元数据不一致"},
|
||
{"missing_comment", func(value string) string {
|
||
return strings.Replace(value, "\n COMMENT ", "\n REMARK ", 1)
|
||
}, "注释声明不完整"},
|
||
{"lossy_comment", func(value string) string {
|
||
return strings.Replace(value, "\n COMMENT '", "\n COMMENT '中文", 1)
|
||
}, "无法无损还原"},
|
||
{"invalid_utf8_comment", func(value string) string {
|
||
return strings.Replace(value, "\n COMMENT '", "\n COMMENT 'é", 1)
|
||
}, "无法无损还原"},
|
||
} {
|
||
t.Run(item.name, func(t *testing.T) {
|
||
definition, err := readMySQLStoredDefinition(t.Context(), tx, routine.kind, routine.name, column)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
definition[column] = item.alter(definition[column])
|
||
original := definition[column]
|
||
err = normalizeMySQLRoutineMetadata(t.Context(), tx, routine, definition, column)
|
||
if err == nil || !strings.Contains(err.Error(), item.message) {
|
||
t.Fatalf("无效例程元数据必须明确拒绝:%v", err)
|
||
}
|
||
if definition[column] != original {
|
||
t.Fatal("失败时不能写回部分转换的定义")
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func readMySQLRoutineDefinitions(t *testing.T, db *sql.DB) string {
|
||
t.Helper()
|
||
queries := []string{
|
||
`SELECT JSON_ARRAY(ROUTINE_NAME, ROUTINE_TYPE, DTD_IDENTIFIER, ROUTINE_BODY, ROUTINE_DEFINITION,
|
||
IS_DETERMINISTIC, SQL_DATA_ACCESS, SECURITY_TYPE, SQL_MODE, ROUTINE_COMMENT, DEFINER,
|
||
CHARACTER_SET_CLIENT, COLLATION_CONNECTION, DATABASE_COLLATION)
|
||
FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = DATABASE() ORDER BY ROUTINE_TYPE, ROUTINE_NAME`,
|
||
`SELECT JSON_ARRAY(SPECIFIC_NAME, ORDINAL_POSITION, PARAMETER_MODE, PARAMETER_NAME,
|
||
DTD_IDENTIFIER, CHARACTER_SET_NAME, COLLATION_NAME, ROUTINE_TYPE)
|
||
FROM information_schema.PARAMETERS WHERE SPECIFIC_SCHEMA = DATABASE() ORDER BY SPECIFIC_NAME, ORDINAL_POSITION`,
|
||
}
|
||
var definitions []string
|
||
for _, query := range queries {
|
||
rows, err := db.QueryContext(t.Context(), query)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for rows.Next() {
|
||
var definition string
|
||
if err := rows.Scan(&definition); err != nil {
|
||
_ = rows.Close()
|
||
t.Fatal(err)
|
||
}
|
||
definitions = append(definitions, definition)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
_ = rows.Close()
|
||
t.Fatal(err)
|
||
}
|
||
if err := rows.Close(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
return strings.Join(definitions, "\n")
|
||
}
|