Scert/backend/internal/cli/mysql_routines_integration_test.go
xuans 777c7169b6 完善MySQL视图备份恢复及完整清单权限校验
保留原始视图定义与编码,按依赖排序导出,并严格识别和重排官方最终视图块;增加运行上下文和权限检查。补充视图、清单、恢复失败保护测试,并修正旧事件、触发器和例程测试的权限前提。

隔离MySQL 5.7和8.0完整专项分别通过176和202个叶子用例,无失败或跳过;后端常规全量、静态检查、模块校验及Linux测试包交叉编译通过。测试产物和凭据未提交。本提交不代表生产部署验收完成。
2026-09-28 00:35:26 +08:00

338 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
if scenario == "partial_revoke" || scenario == "partial_revoke_with_show" {
db, err := database.Open(t.Context(), cfg)
if err != nil {
t.Fatal(err)
}
defer db.Close()
tx, err := db.DB.BeginTx(t.Context(), nil)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
routines, visibilityErr := listMySQLRoutines(t.Context(), tx)
if err := tx.Rollback(); err != nil {
t.Fatal(err)
}
if allowed {
if visibilityErr != nil || len(routines) != 1 || routines[0].name != "hidden_routine" {
t.Fatalf("SHOW_ROUTINE 未独立保证例程可见:%v", visibilityErr)
}
} else if visibilityErr == nil || !strings.Contains(visibilityErr.Error(), "例程") {
t.Fatalf("全局 SELECT 被部分撤销后例程权限检查未拒绝:%v", visibilityErr)
}
if err := backupMySQLDatabase(t.Context(), cfg, output); err == nil || !strings.Contains(err.Error(), "SELECT") {
t.Fatalf("例程可见权限不能替代完整表清单的读取权限:%v", err)
}
assertMySQLBackupFile(t, output, []byte("existing backup"))
assertMySQLBackupAbsent(t, output+".tmp")
if !allowed {
return
}
if _, err := server.Admin().ExecContext(t.Context(), "GRANT SELECT ON "+quoteMySQLIdentifier(parsed.DBName)+".* TO "+account); 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")
}