保留原始视图定义与编码,按依赖排序导出,并严格识别和重排官方最终视图块;增加运行上下文和权限检查。补充视图、清单、恢复失败保护测试,并修正旧事件、触发器和例程测试的权限前提。 隔离MySQL 5.7和8.0完整专项分别通过176和202个叶子用例,无失败或跳过;后端常规全量、静态检查、模块校验及Linux测试包交叉编译通过。测试产物和凭据未提交。本提交不代表生产部署验收完成。
247 lines
11 KiB
Go
247 lines
11 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 TestMySQLRealNativeTriggers(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)
|
||
// 仅给独立实例的专用账户补足启用二进制日志时的触发器创建权限。
|
||
if server.IsMySQL8() {
|
||
if _, err := server.Admin().ExecContext(t.Context(), "GRANT SUPER ON *.* TO "+mysqlStringLiteral(parsed.User)+"@'127.0.0.1'"); 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",
|
||
"CREATE TABLE audit_values (value INT NOT NULL) ENGINE=InnoDB",
|
||
"INSERT INTO interop VALUES (1, 10, '原始值')",
|
||
"CREATE TRIGGER z_first BEFORE INSERT ON interop FOR EACH ROW BEGIN SET NEW.value = NEW.value + 2; SET NEW.label = CONCAT(NEW.label, ';第一步'); END",
|
||
"CREATE TRIGGER a_second BEFORE INSERT ON interop FOR EACH ROW FOLLOWS z_first BEGIN SET NEW.value = NEW.value * 3; SET NEW.label = CONCAT(NEW.label, ';第二步'); END",
|
||
"CREATE TRIGGER audit_insert AFTER INSERT ON interop FOR EACH ROW BEGIN INSERT INTO audit_values VALUES (NEW.value); END",
|
||
"CREATE TABLE legacy_values (id INT PRIMARY KEY, label VARCHAR(100)) ENGINE=InnoDB",
|
||
"ALTER DATABASE COLLATE latin1_swedish_ci",
|
||
"SET NAMES latin1 COLLATE latin1_bin",
|
||
"CREATE TRIGGER legacy_insert BEFORE INSERT ON legacy_values FOR EACH ROW SET NEW.label = CONCAT(NEW.label, '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 := readMySQLTriggerDefinitions(t, db.DB)
|
||
if strings.Count(definition, "\n") != 3 {
|
||
t.Fatal("源库四个触发器没有完整创建")
|
||
}
|
||
output := filepath.Join(server.Root(), "native-triggers.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()
|
||
if direction == "scert_to_official" {
|
||
server.Import(t, target, output)
|
||
} else if err := restoreMySQL(t.Context(), target, output); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
verify, err := database.Open(t.Context(), target)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer verify.Close()
|
||
if restored := readMySQLTriggerDefinitions(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 CAST(COUNT(*) AS CHAR) FROM audit_values", "0")
|
||
if _, err := verify.DB.ExecContext(t.Context(), "INSERT INTO interop VALUES (2, 4, '新行')"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
checkMySQLInteropValue(t, target, "SELECT CONCAT(value, '|', label) FROM interop WHERE id = 2", "18|新行;第一步;第二步")
|
||
checkMySQLInteropValue(t, target, "SELECT CAST(value AS CHAR) FROM audit_values", "18")
|
||
if _, err := verify.DB.ExecContext(t.Context(), "INSERT INTO legacy_values VALUES (1, '')"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
checkMySQLInteropValue(t, target, "SELECT label FROM legacy_values", "café;;")
|
||
checkMySQLInteropValue(t, target, "SELECT @@collation_database", "utf8mb4_unicode_ci")
|
||
})
|
||
}
|
||
t.Run("hidden_triggers_are_not_silently_lost", func(t *testing.T) {
|
||
if _, err := server.Admin().ExecContext(t.Context(), "REVOKE TRIGGER ON "+quoteMySQLIdentifier(parsed.DBName)+".* FROM "+mysqlStringLiteral(parsed.User)+"@'127.0.0.1'"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
checkMySQLInteropValue(t, source, "SELECT CAST(COUNT(*) AS CHAR) FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA = DATABASE()", "0")
|
||
failed := filepath.Join(server.Root(), "hidden-triggers.sql")
|
||
if err := os.WriteFile(failed, []byte("existing backup"), 0o600); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := backupMySQLDatabase(t.Context(), source, failed); err == nil || !strings.Contains(err.Error(), "TRIGGER") {
|
||
t.Fatalf("没有触发器可见权限时必须明确失败:%v", err)
|
||
}
|
||
data, err := os.ReadFile(failed)
|
||
if err != nil || string(data) != "existing backup" {
|
||
t.Fatal("权限不足破坏了已有备份")
|
||
}
|
||
if _, err := os.Stat(failed + ".tmp"); !os.IsNotExist(err) {
|
||
t.Fatal("权限不足遗留临时文件")
|
||
}
|
||
})
|
||
}
|
||
|
||
func TestMySQLRealTriggerPrivileges(t *testing.T) {
|
||
server := mysqltest.New(t)
|
||
scenarios := []string{"schema", "global", "per_table", "partial_table", "unrelated_schema", "escaped_schema", "escaped_identifier", "shadowed_schema", "shadowed_exact"}
|
||
if server.IsMySQL8() {
|
||
scenarios = append(scenarios, "global_dynamic", "active_role", "nested_role", "inactive_role", "partial_revoke")
|
||
}
|
||
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'"
|
||
schema := quoteMySQLIdentifier(parsed.DBName)
|
||
first, second := "first_table", "second_table"
|
||
if scenario == "escaped_identifier" {
|
||
first, second = "first`table`", "second_table%"
|
||
}
|
||
statements := []string{
|
||
"CREATE TABLE " + schema + "." + quoteMySQLIdentifier(first) + " (id INT) ENGINE=InnoDB",
|
||
"CREATE TABLE " + schema + "." + quoteMySQLIdentifier(second) + " (id INT) ENGINE=InnoDB",
|
||
"REVOKE TRIGGER ON " + schema + ".* FROM " + account,
|
||
}
|
||
switch scenario {
|
||
case "schema":
|
||
statements = append(statements, "GRANT TRIGGER ON "+schema+".* TO "+account)
|
||
case "escaped_schema":
|
||
escaped := strings.ReplaceAll(parsed.DBName, "_", string(rune(92))+"_")
|
||
statements = append(statements, "GRANT SELECT, TRIGGER, EVENT ON "+quoteMySQLIdentifier(escaped)+".* TO "+account)
|
||
case "shadowed_schema":
|
||
statements = append(statements, "GRANT SELECT ON *.* TO "+account, "GRANT TRIGGER ON `scert%`.* TO "+account)
|
||
case "shadowed_exact":
|
||
escaped := strings.ReplaceAll(parsed.DBName, "_", string(rune(92))+"_")
|
||
statements = append(statements, "GRANT TRIGGER ON "+schema+".* TO "+account, "GRANT SELECT ON "+quoteMySQLIdentifier(escaped)+".* TO "+account)
|
||
case "global":
|
||
statements = append(statements, "GRANT TRIGGER ON *.* TO "+account)
|
||
case "global_dynamic":
|
||
statements = append(statements, "GRANT TRIGGER, BACKUP_ADMIN ON *.* TO "+account)
|
||
case "per_table", "escaped_identifier":
|
||
statements = append(statements, "GRANT TRIGGER ON "+schema+"."+quoteMySQLIdentifier(first)+" TO "+account,
|
||
"GRANT TRIGGER ON "+schema+"."+quoteMySQLIdentifier(second)+" TO "+account)
|
||
case "partial_table":
|
||
statements = append(statements, "GRANT TRIGGER ON "+schema+"."+quoteMySQLIdentifier(first)+" TO "+account)
|
||
case "unrelated_schema":
|
||
statements = append(statements, "GRANT TRIGGER ON `another_database`.* TO "+account)
|
||
case "active_role", "nested_role", "inactive_role":
|
||
role := quoteMySQLIdentifier("trigger_role_"+scenario) + "@'%'"
|
||
statements = append(statements, "CREATE ROLE "+role, "GRANT TRIGGER ON "+schema+".* TO "+role, "GRANT "+role+" TO "+account)
|
||
if scenario == "active_role" {
|
||
statements = append(statements, "SET DEFAULT ROLE "+role+" TO "+account)
|
||
}
|
||
if scenario == "nested_role" {
|
||
parent := "'trigger_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":
|
||
statements = append(statements, "SET GLOBAL partial_revokes = ON", "GRANT TRIGGER ON *.* TO "+account, "REVOKE TRIGGER ON "+schema+".* FROM "+account)
|
||
}
|
||
for _, statement := range statements {
|
||
if _, err := server.Admin().ExecContext(t.Context(), statement); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
for i, table := range []string{first, second} {
|
||
trigger := quoteMySQLIdentifier("probe_" + string(rune('a'+i)))
|
||
statement := "CREATE TRIGGER " + schema + "." + trigger + " BEFORE INSERT ON " + schema + "." + quoteMySQLIdentifier(table) + " FOR EACH ROW SET NEW.id = NEW.id + 1"
|
||
if _, err := server.Admin().ExecContext(t.Context(), statement); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
output := filepath.Join(server.Root(), "privilege-"+scenario+".sql")
|
||
err = backupMySQLDatabase(t.Context(), cfg, output)
|
||
denied := scenario == "partial_table" || scenario == "unrelated_schema" || scenario == "inactive_role" || scenario == "partial_revoke" || scenario == "shadowed_schema" || scenario == "shadowed_exact"
|
||
if denied {
|
||
if err == nil || !strings.Contains(err.Error(), "TRIGGER") {
|
||
t.Fatalf("缺少有效权限时没有明确拒绝备份:%v", err)
|
||
}
|
||
if _, err := os.Stat(output); !os.IsNotExist(err) {
|
||
t.Fatal("权限不足仍发布了备份")
|
||
}
|
||
} else if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
count := "2"
|
||
if denied {
|
||
count = "0"
|
||
if scenario == "partial_table" {
|
||
count = "1"
|
||
}
|
||
}
|
||
checkMySQLInteropValue(t, cfg, "SELECT CAST(COUNT(*) AS CHAR) FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA = DATABASE()", count)
|
||
})
|
||
}
|
||
}
|
||
|
||
func readMySQLTriggerDefinitions(t *testing.T, db *sql.DB) string {
|
||
t.Helper()
|
||
rows, err := db.QueryContext(t.Context(), `SELECT JSON_ARRAY(TRIGGER_NAME, EVENT_MANIPULATION,
|
||
EVENT_OBJECT_TABLE, ACTION_ORDER, ACTION_STATEMENT, ACTION_ORIENTATION, ACTION_TIMING,
|
||
SQL_MODE, DEFINER, CHARACTER_SET_CLIENT, COLLATION_CONNECTION, DATABASE_COLLATION)
|
||
FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA = DATABASE() ORDER BY TRIGGER_NAME`)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer rows.Close()
|
||
var definitions []string
|
||
for rows.Next() {
|
||
var definition string
|
||
if err := rows.Scan(&definition); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
definitions = append(definitions, definition)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return strings.Join(definitions, "\n")
|
||
}
|