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

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

113 lines
3.9 KiB
Go

package cli
import (
"path/filepath"
"slices"
"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 TestMySQLRealViewRestorePlanning(t *testing.T) {
server := mysqltest.New(t)
cfg := server.Database(t)
dsn, err := mysql.ParseDSN(cfg.DSN)
if err != nil {
t.Fatal(err)
}
grantMySQLViewAccess(t, server, dsn.User, dsn.DBName)
db, err := database.Open(t.Context(), cfg)
if err != nil {
t.Fatal(err)
}
defer db.Close()
conn, err := db.DB.Conn(t.Context())
if err != nil {
t.Fatal(err)
}
defer conn.Close()
a := mysqlViewRestoreFixture("a", "select * from z")
z := mysqlViewRestoreFixture("z", "select 1 AS id")
input := append(slices.Clone(a), z...)
original := slices.Clone(input)
plan, err := prepareMySQLViewRestore(t.Context(), conn, input)
if err != nil {
t.Fatal(err)
}
want := append(slices.Clone(z), a...)
if !slices.Equal(plan.statements, want) || !slices.Equal(input, original) {
t.Fatal("恢复预检未整块排序,或改写了原始语句")
}
if err := plan.checkContext(t.Context(), conn, 0); err != nil {
t.Fatal(err)
}
for _, boundary := range []string{"INSERT INTO marker VALUES (99)", "SET @unrelated = 1", "USE another_database"} {
t.Run(boundary, func(t *testing.T) {
separated := append(append(slices.Clone(a), boundary), z...)
plan, err := prepareMySQLViewRestore(t.Context(), conn, separated)
if err != nil || !slices.Equal(plan.statements, separated) {
t.Fatalf("视图块越过外部语句被重排:%v", err)
}
})
}
for _, statement := range []string{"SET SESSION sql_mode = 'ANSI_QUOTES'", "SET SESSION sql_mode = 'NO_BACKSLASH_ESCAPES'", "SET NAMES latin1"} {
t.Run(statement, func(t *testing.T) {
if _, err := conn.ExecContext(t.Context(), statement); err != nil {
t.Fatal(err)
}
if err := plan.checkContext(t.Context(), conn, 0); err == nil {
t.Fatal("恢复时的实际语义变更没有被拒绝")
}
if _, err := conn.ExecContext(t.Context(), "SET SESSION sql_mode = ''"); err != nil {
t.Fatal(err)
}
if _, err := conn.ExecContext(t.Context(), "SET NAMES utf8mb4"); err != nil {
t.Fatal(err)
}
})
}
for _, statement := range []string{"CREATE TABLE marker(value INT) ENGINE=InnoDB", "INSERT INTO marker VALUES (73)"} {
if _, err := conn.ExecContext(t.Context(), statement); err != nil {
t.Fatal(err)
}
}
for _, scenario := range []string{"cycle", "duplicate", "name_mismatch", "invalid_charset"} {
t.Run(scenario, func(t *testing.T) {
blocks := slices.Clone(input)
switch scenario {
case "cycle":
blocks = append(slices.Clone(a), mysqlViewRestoreFixture("z", "select * from a")...)
case "duplicate":
blocks = append(slices.Clone(a), a...)
case "name_mismatch":
blocks[0] = "/*!50001 DROP VIEW IF EXISTS `other` */"
case "invalid_charset":
blocks[4] = "/*!50001 SET character_set_client = unknown_charset */"
}
script := append([]string{"DROP TABLE marker"}, blocks...)
file := filepath.Join(server.Root(), "view-plan-"+scenario+".sql")
writeMySQLBackupFixture(t, file, []byte(strings.Join(script, ";\n")+";\n"))
if err := restoreMySQL(t.Context(), cfg, file); err == nil || !strings.Contains(err.Error(), "预检 MySQL 视图恢复依赖失败") {
t.Fatalf("无效视图恢复计划未在执行任何输入语句前拒绝:%v", err)
}
checkMySQLInteropValue(t, cfg, "SELECT value FROM marker", "73")
})
}
other := server.Database(t)
otherDSN, err := mysql.ParseDSN(other.DSN)
if err != nil {
t.Fatal(err)
}
grantMySQLViewAccess(t, server, dsn.User, otherDSN.DBName)
if _, err := conn.ExecContext(t.Context(), "USE "+quoteMySQLIdentifier(otherDSN.DBName)); err != nil {
t.Fatal(err)
}
if err := plan.checkContext(t.Context(), conn, 0); err == nil {
t.Fatal("数据库切换后仍使用原库的依赖计划")
}
}