保留原始视图定义与编码,按依赖排序导出,并严格识别和重排官方最终视图块;增加运行上下文和权限检查。补充视图、清单、恢复失败保护测试,并修正旧事件、触发器和例程测试的权限前提。 隔离MySQL 5.7和8.0完整专项分别通过176和202个叶子用例,无失败或跳过;后端常规全量、静态检查、模块校验及Linux测试包交叉编译通过。测试产物和凭据未提交。本提交不代表生产部署验收完成。
232 lines
8.5 KiB
Go
232 lines
8.5 KiB
Go
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")
|
||
}
|