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

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

232 lines
8.5 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 (
"crypto/sha256"
"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 TestMySQLRealNativeViews(t *testing.T) {
server := mysqltest.New(t)
source := server.Database(t)
parsed, err := mysql.ParseDSN(source.DSN)
if err != nil {
t.Fatal(err)
}
grantMySQLViewAccess(t, server, parsed.User, parsed.DBName)
db, err := database.Open(t.Context(), source)
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()
for _, statement := range []string{
"CREATE TABLE source_values (id INT PRIMARY KEY, amount DECIMAL(25,8), label VARCHAR(80)) ENGINE=InnoDB",
"INSERT INTO source_values VALUES (1, 12345678901234567.12345678, '原始值'), (2, -1.25000000, '负数')",
"CREATE FUNCTION view_label(input_value VARCHAR(80)) RETURNS VARCHAR(100) DETERMINISTIC NO SQL RETURN CONCAT('函数;', input_value)",
"CREATE ALGORITHM=MERGE SQL SECURITY INVOKER VIEW z_base AS SELECT id, amount, label FROM source_values WHERE id > 0 WITH LOCAL CHECK OPTION",
"CREATE ALGORITHM=MERGE SQL SECURITY DEFINER VIEW a_nested AS SELECT id, amount, label FROM z_base WHERE id = 1 WITH CASCADED CHECK OPTION",
"CREATE ALGORITHM=TEMPTABLE SQL SECURITY INVOKER VIEW `中文``汇总` AS SELECT SUM(amount) AS `金额`, view_label(MAX(label)) AS `说明` FROM a_nested",
"SET NAMES latin1 COLLATE latin1_bin",
"CREATE SQL SECURITY INVOKER VIEW `legacy_caf" + string([]byte{0xe9}) + "` AS SELECT 'caf" + string([]byte{0xe9}) + ";;' AS label",
"SET NAMES utf8mb4 COLLATE utf8mb4_bin",
} {
if _, err := conn.ExecContext(t.Context(), statement); err != nil {
t.Fatal(err)
}
}
original := readMySQLViewDefinitions(t, db.DB)
output := filepath.Join(server.Root(), "native-views.sql")
if err := backupMySQLDatabase(t.Context(), source, output); err != nil {
t.Fatal(err)
}
official := filepath.Join(server.Root(), "official-views.sql")
server.DumpStoredPrograms(t, source, official)
before, err := os.ReadFile(official)
if err != nil {
t.Fatal(err)
}
originalDigest := sha256.Sum256(before)
t.Run("official_to_official_control", func(t *testing.T) {
target := server.Database(t)
dsn, err := mysql.ParseDSN(target.DSN)
if err != nil {
t.Fatal(err)
}
grantMySQLViewAccess(t, server, parsed.User, dsn.DBName)
dsn.User = parsed.User
target.DSN = dsn.FormatDSN()
if code := server.ImportErrorCode(t, target, official); code != 1368 {
t.Fatalf("官方原样恢复未复现嵌套检查选项错误:%d", code)
}
t.Log("同一份未经改写的官方导出文件,官方客户端返回 1368;Scert 必须单独验证成功恢复,不能用此失败对照替代")
})
for _, direction := range []string{"scert_to_scert", "scert_to_official", "official_to_scert"} {
t.Run(direction, func(t *testing.T) {
target := server.Database(t)
targetDSN, err := mysql.ParseDSN(target.DSN)
if err != nil {
t.Fatal(err)
}
grantMySQLViewAccess(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 VIEW a_nested AS SELECT 999 AS wrong_column"); err != nil {
t.Fatal(err)
}
for attempt := 0; attempt < 2; attempt++ {
input := output
if direction == "official_to_scert" {
input = official
}
if direction == "scert_to_official" {
server.Import(t, target, input)
} else if err := restoreMySQL(t.Context(), target, input); err != nil {
t.Fatal(err)
}
if actual := readMySQLViewDefinitions(t, verify.DB); actual != original {
t.Fatalf("恢复后的视图定义或列元数据不一致:\n源:%s\n目标:%s", original, actual)
}
checkMySQLInteropValue(t, target, "SELECT CONCAT(id, '|', amount, '|', label) FROM a_nested", "1|12345678901234567.12345678|原始值")
checkMySQLInteropValue(t, target, "SELECT label FROM `legacy_café`", "café;;")
if _, err := verify.DB.ExecContext(t.Context(), "UPDATE a_nested SET label = '更新值' WHERE id = 1"); err != nil {
t.Fatal(err)
}
checkMySQLInteropValue(t, target, "SELECT `说明` FROM `中文``汇总`", "函数;更新值")
if _, err := verify.DB.ExecContext(t.Context(), "UPDATE a_nested SET id = 3 WHERE id = 1"); err == nil {
t.Fatal("恢复后视图的检查选项未阻止越界写入")
}
checkMySQLInteropValue(t, source, "SELECT label FROM source_values WHERE id = 1", "原始值")
}
})
}
after, err := os.ReadFile(official)
if err != nil || sha256.Sum256(after) != originalDigest {
t.Fatal("恢复过程改写了官方原始导出文件")
}
t.Run("existing_table_is_not_deleted", func(t *testing.T) {
target := server.Database(t)
dsn, err := mysql.ParseDSN(target.DSN)
if err != nil {
t.Fatal(err)
}
grantMySQLViewAccess(t, server, parsed.User, dsn.DBName)
dsn.User = parsed.User
target.DSN = dsn.FormatDSN()
verify, err := database.Open(t.Context(), target)
if err != nil {
t.Fatal(err)
}
defer verify.Close()
for _, statement := range []string{"CREATE TABLE a_nested (marker INT)", "INSERT INTO a_nested VALUES (73)"} {
if _, err := verify.DB.ExecContext(t.Context(), statement); err != nil {
t.Fatal(err)
}
}
if err := restoreMySQL(t.Context(), target, output); err == nil {
t.Fatal("视图与目标已有基础表同名时没有拒绝恢复")
}
checkMySQLInteropValue(t, target, "SELECT marker FROM a_nested", "73")
})
}
func grantMySQLViewAccess(t *testing.T, server *mysqltest.Server, user, name string) {
t.Helper()
grantMySQLProgramAccess(t, server, user, name)
if _, err := server.Admin().ExecContext(t.Context(), "GRANT CREATE VIEW, SHOW VIEW ON "+quoteMySQLIdentifier(name)+".* TO "+mysqlStringLiteral(user)+"@'127.0.0.1'"); err != nil {
t.Fatal(err)
}
if server.IsMySQL8() {
if _, err := server.Admin().ExecContext(t.Context(), "GRANT SUPER ON *.* TO "+mysqlStringLiteral(user)+"@'127.0.0.1'"); err != nil {
t.Fatal(err)
}
}
}
func readMySQLViewDefinitions(t *testing.T, db *sql.DB) string {
t.Helper()
rows, err := db.QueryContext(t.Context(), "SELECT TABLE_NAME FROM information_schema.VIEWS WHERE TABLE_SCHEMA = DATABASE() ORDER BY TABLE_NAME")
if err != nil {
t.Fatal(err)
}
var names []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
t.Fatal(err)
}
names = append(names, name)
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
rows.Close()
var values []string
for _, name := range names {
var actual, definition, charset, collation string
if err := db.QueryRowContext(t.Context(), "SHOW CREATE VIEW "+quoteMySQLIdentifier(name)).Scan(&actual, &definition, &charset, &collation); err != nil {
t.Fatal(err)
}
values = append(values, actual+"|"+definition+"|"+charset+"|"+collation)
}
columns, err := db.QueryContext(t.Context(), `SELECT JSON_ARRAY(c.TABLE_NAME, c.COLUMN_NAME, c.ORDINAL_POSITION,
c.COLUMN_TYPE, c.IS_NULLABLE, c.CHARACTER_SET_NAME, c.COLLATION_NAME)
FROM information_schema.COLUMNS c JOIN information_schema.VIEWS v
ON c.TABLE_SCHEMA = v.TABLE_SCHEMA AND c.TABLE_NAME = v.TABLE_NAME
WHERE c.TABLE_SCHEMA = DATABASE() ORDER BY c.TABLE_NAME, c.ORDINAL_POSITION`)
if err != nil {
t.Fatal(err)
}
defer columns.Close()
for columns.Next() {
var value string
if err := columns.Scan(&value); err != nil {
t.Fatal(err)
}
values = append(values, value)
}
if err := columns.Err(); err != nil {
t.Fatal(err)
}
return strings.Join(values, "\n")
}
func TestMySQLRealViewPermissionFailure(t *testing.T) {
server := mysqltest.New(t)
cfg := server.Database(t)
parsed, err := mysql.ParseDSN(cfg.DSN)
if err != nil {
t.Fatal(err)
}
if _, err := server.Admin().ExecContext(t.Context(), "CREATE VIEW "+quoteMySQLIdentifier(parsed.DBName)+".hidden_definition AS SELECT 1 AS id"); err != nil {
t.Fatal(err)
}
output := filepath.Join(server.Root(), "view-permissions.sql")
if err := os.WriteFile(output, []byte("existing backup"), 0o600); err != nil {
t.Fatal(err)
}
err = backupMySQLDatabase(t.Context(), cfg, output)
if err == nil || !strings.Contains(err.Error(), "SHOW VIEW") {
t.Fatalf("缺少视图定义可见权限时必须明确拒绝:%v", err)
}
assertMySQLBackupFile(t, output, []byte("existing backup"))
assertMySQLBackupAbsent(t, output+".tmp")
}