diff --git a/backend/internal/cli/mysql_backup.go b/backend/internal/cli/mysql_backup.go index 63b082e..78d3374 100644 --- a/backend/internal/cli/mysql_backup.go +++ b/backend/internal/cli/mysql_backup.go @@ -140,6 +140,10 @@ func dumpMySQLTable(ctx context.Context, db *sql.DB, output *os.File, tableName if err != nil { return fmt.Errorf("读取 MySQL 表字段失败(%s):%w", tableName, err) } + columnTypes, err := rows.ColumnTypes() + if err != nil { + return fmt.Errorf("读取 MySQL 表字段类型失败(%s):%w", tableName, err) + } for rows.Next() { values := make([]any, len(columns)) pointers := make([]any, len(columns)) @@ -151,7 +155,7 @@ func dumpMySQLTable(ctx context.Context, db *sql.DB, output *os.File, tableName } literals := make([]string, len(values)) for index, value := range values { - literals[index] = mysqlSQLLiteral(value) + literals[index] = mysqlColumnSQLLiteral(value, columnTypes[index].DatabaseTypeName()) } if _, err := fmt.Fprintf(output, "INSERT INTO %s (%s) VALUES (%s);\n", quotedTable, quoteMySQLColumns(columns), strings.Join(literals, ", ")); err != nil { return fmt.Errorf("写入 MySQL 表数据失败(%s):%w", tableName, err) @@ -176,6 +180,20 @@ func quoteMySQLColumns(columns []string) string { return strings.Join(quoted, ", ") } +func mysqlColumnSQLLiteral(value any, columnType string) string { + // 驱动将 DECIMAL 返回为文本字节;十六进制字面量在数值列中会被当作整数。 + // 使用文本字面量保留任意精度,不经浮点转换;真正的二进制列仍按原样导出。 + if columnType == "DECIMAL" { + switch typed := value.(type) { + case sql.RawBytes: + return mysqlStringLiteral(string(typed)) + case []byte: + return mysqlStringLiteral(string(typed)) + } + } + return mysqlSQLLiteral(value) +} + func mysqlSQLLiteral(value any) string { switch typed := value.(type) { case nil: @@ -243,8 +261,14 @@ func restoreMySQL(ctx context.Context, cfg config.DatabaseConfig, input string) return fmt.Errorf("打开 MySQL 恢复连接失败:%w", err) } defer db.Close() + // 会话变量与外键开关必须留在同一条连接上;失败后返回,不切换连接重放。 + connection, err := db.DB.Conn(ctx) + if err != nil { + return fmt.Errorf("获取 MySQL 恢复会话失败:%w", err) + } + defer connection.Close() for _, statement := range splitMySQLStatements(string(script)) { - if _, err := db.DB.ExecContext(ctx, statement); err != nil { + if _, err := connection.ExecContext(ctx, statement); err != nil { return fmt.Errorf("执行 MySQL 恢复语句失败:%w", err) } } diff --git a/backend/internal/cli/mysql_backup_test.go b/backend/internal/cli/mysql_backup_test.go index 33a6c61..7110deb 100644 --- a/backend/internal/cli/mysql_backup_test.go +++ b/backend/internal/cli/mysql_backup_test.go @@ -40,6 +40,28 @@ func TestQuoteMySQLColumns(t *testing.T) { t.Fatalf("MySQL 字段列表不正确:%s", value) } } + +func TestMySQLColumnSQLLiteral(t *testing.T) { + for _, item := range []struct { + name string + value any + columnType string + want string + }{ + {name: "decimal", value: []byte("123456789.123456"), columnType: "DECIMAL", want: "'123456789.123456'"}, + {name: "precision", value: sql.RawBytes("-12345678901234567890123456789012345.123456789012345678901234567890"), columnType: "DECIMAL", want: "'-12345678901234567890123456789012345.123456789012345678901234567890'"}, + {name: "null", value: nil, columnType: "DECIMAL", want: "NULL"}, + {name: "string", value: "0.000001", columnType: "DECIMAL", want: "'0.000001'"}, + {name: "binary", value: []byte("12"), columnType: "BLOB", want: "X'3132'"}, + } { + t.Run(item.name, func(t *testing.T) { + if got := mysqlColumnSQLLiteral(item.value, item.columnType); got != item.want { + t.Fatalf("字段字面量不正确:%s", got) + } + }) + } +} + func TestSplitMySQLStatementsKeepsQuotedSemicolons(t *testing.T) { script := "-- 注释\nSET FOREIGN_KEY_CHECKS=0;\nINSERT INTO `items` (`value`) VALUES ('a; b'), ('it''s');\n" statements := splitMySQLStatements(script) diff --git a/backend/internal/cli/mysql_commands_integration_test.go b/backend/internal/cli/mysql_commands_integration_test.go new file mode 100644 index 0000000..a5b9a26 --- /dev/null +++ b/backend/internal/cli/mysql_commands_integration_test.go @@ -0,0 +1,198 @@ +package cli + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/go-sql-driver/mysql" + "github.com/spf13/cobra" + + "git.pcux.cn/xuans/Scert/backend/internal/config" + "git.pcux.cn/xuans/Scert/backend/internal/database" + "git.pcux.cn/xuans/Scert/backend/internal/secretstore" + "git.pcux.cn/xuans/Scert/backend/internal/testsupport/mysqltest" +) + +// 在同一进程调用正式命令;测试 TLS 注册名不能用于独立发行二进制验收。 +func TestMySQLRealBackupRestoreCommands(t *testing.T) { + server := mysqltest.New(t) + for _, scenario := range []string{"success", "sql_failure", "certificate_failure"} { + t.Run(scenario, func(t *testing.T) { + connection := server.Database(t) + connection.MaxIdleConns = 0 + db, err := database.Open(t.Context(), connection) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := database.Migrate(t.Context(), db.DB, "mysql"); err != nil { + t.Fatal(err) + } + exec := func(statement string) { + t.Helper() + if _, err := db.DB.ExecContext(t.Context(), statement); err != nil { + t.Fatal(err) + } + } + exec("INSERT INTO settings (`key`, value) VALUES ('integration', 'backup')") + exec("INSERT INTO audit_logs (action, resource_type, resource_id, status, message) VALUES ('backup', 'test', '1', 'ok', 'backup')") + root := filepath.Join(server.Root(), scenario) + cfg := config.Default(root) + cfg.Paths = config.PathsConfig{ + Install: root, Config: filepath.Join(root, "conf"), Data: filepath.Join(root, "data"), + Certificate: filepath.Join(root, "ssl"), Log: filepath.Join(root, "log"), Backup: filepath.Join(root, "snapshots"), + } + parsed, err := mysql.ParseDSN(connection.DSN) + if err != nil { + t.Fatal("解析隔离数据库连接失败") + } + username, password := parsed.User, parsed.Passwd + parsed.User, parsed.Passwd = "", "" + cfg.Database = connection + cfg.Database.DSN = parsed.FormatDSN() + cfg.Database.CredentialRef = "secret://database/maintenance" + current := filepath.Join(cfg.Paths.Config, "scert.json") + if err := cfg.Save(current); err != nil { + t.Fatal(err) + } + store, err := secretstore.Open(cfg.Paths.Config) + if err != nil { + t.Fatal(err) + } + if err := store.Put(t.Context(), cfg.Database.CredentialRef, map[string]string{"username": username, "password": password}); err != nil { + t.Fatal(err) + } + write := func(path string, content []byte) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatal(err) + } + } + read := func(path string) []byte { + t.Helper() + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return content + } + certificate := filepath.Join(cfg.Paths.Certificate, "integration.pem") + write(certificate, read(filepath.Join(server.Root(), "server.crt"))) + previous := configPath + configPath = current + t.Cleanup(func() { configPath = previous }) + run := func(command *cobra.Command, args ...string) (string, error) { + t.Helper() + var output bytes.Buffer + command.SetArgs(args) + command.SetContext(t.Context()) + command.SetOut(&output) + command.SetErr(&output) + err := command.Execute() + if strings.Contains(output.String(), password) || (err != nil && strings.Contains(err.Error(), password)) { + t.Fatal("命令输出不得泄漏数据库密码") + } + return output.String(), err + } + input := filepath.Join(root, "backup") + if _, err := run(newBackupCommand(), "--output", input); err != nil { + t.Fatal(err) + } + if err := validateBackupManifest(input); err != nil { + t.Fatal(err) + } + exec("UPDATE settings SET value = 'current' WHERE `key` = 'integration'") + exec("INSERT INTO audit_logs (action, resource_type, resource_id, status, message) VALUES ('current', 'test', '2', 'ok', 'current')") + cfg.Server.Port++ + if err := cfg.Save(current); err != nil { + t.Fatal(err) + } + if err := store.Put(t.Context(), "secret://integration/marker", map[string]string{"value": "current"}); err != nil { + t.Fatal(err) + } + write(certificate, []byte("current-certificate")) + before := make(map[string][]byte) + for _, name := range []string{"scert.json", "master.key", "secrets.json"} { + before[name] = read(filepath.Join(cfg.Paths.Config, name)) + } + if scenario == "sql_failure" { + // 保持清单有效,以确认错误发生在实际导入阶段而非哈希校验阶段。 + path := filepath.Join(input, "scert.sql") + write(path, append(read(path), []byte("\nINSERT INTO missing_restore_table VALUES (1);\n")...)) + manifest, err := buildBackupManifestExcludingManifest(input) + if err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + write(filepath.Join(input, "manifest.json"), encoded) + if err := validateBackupManifest(input); err != nil { + t.Fatal(err) + } + } + if scenario == "certificate_failure" { + if err := os.Rename(cfg.Paths.Certificate, cfg.Paths.Certificate+"-preserved"); err != nil { + t.Fatal(err) + } + write(cfg.Paths.Certificate, []byte("blocked-target")) + } + output, err := run(newRestoreCommand(), "--input", input) + want, count := "backup", 1 + if scenario == "success" { + if err != nil || !strings.Contains(output, "恢复已完成") { + t.Fatalf("正式命令恢复失败:%v", err) + } + for name := range before { + if !bytes.Equal(read(filepath.Join(cfg.Paths.Config, name)), read(filepath.Join(input, name))) { + t.Fatalf("未恢复备份配置:%s", name) + } + } + if !bytes.Equal(read(certificate), read(filepath.Join(input, "certificates", "integration.pem"))) { + t.Fatal("未恢复证书文件") + } + } else { + want, count = "current", 2 + phase := "恢复 MySQL 数据库失败" + if scenario == "certificate_failure" { + phase = "恢复敏感配置或证书失败" + } + if err == nil || !strings.Contains(err.Error(), phase) || strings.Contains(output, "恢复已完成") { + t.Fatalf("未在指定阶段失败:%v", err) + } + for name, content := range before { + if !bytes.Equal(read(filepath.Join(cfg.Paths.Config, name)), content) { + t.Fatalf("失败后未恢复当前配置:%s", name) + } + } + preserved := certificate + if scenario == "certificate_failure" { + preserved = filepath.Join(cfg.Paths.Certificate+"-preserved", "integration.pem") + } + if string(read(preserved)) != "current-certificate" { + t.Fatal("失败后原证书内容被修改") + } + } + var value string + var rows int + if err := db.DB.QueryRowContext(t.Context(), "SELECT value FROM settings WHERE `key` = 'integration'").Scan(&value); err != nil || value != want { + t.Fatalf("恢复或回滚后的业务数据错误:%v", err) + } + if err := db.DB.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM audit_logs").Scan(&rows); err != nil || rows != count { + t.Fatalf("恢复或回滚后的审计记录错误:%v", err) + } + snapshots, err := filepath.Glob(filepath.Join(cfg.Paths.Backup, "scert-before-restore-*", "scert.sql")) + if err != nil || len(snapshots) != 1 { + t.Fatal("必须保留恢复前的数据库快照") + } + }) + } +} diff --git a/backend/internal/cli/mysql_integration_test.go b/backend/internal/cli/mysql_integration_test.go new file mode 100644 index 0000000..ab8c835 --- /dev/null +++ b/backend/internal/cli/mysql_integration_test.go @@ -0,0 +1,128 @@ +package cli + +import ( + "bytes" + "context" + "database/sql" + "os" + "path/filepath" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/database" + "git.pcux.cn/xuans/Scert/backend/internal/testsupport/mysqltest" +) + +func TestMySQLRealBackupRestore(t *testing.T) { + server := mysqltest.New(t) + t.Run("session_identity", func(t *testing.T) { + cfg := server.Database(t) + cfg.MaxIdleConns = 0 + input := filepath.Join(server.Root(), "session.sql") + script := "CREATE TABLE session_probe (id BIGINT NOT NULL, original_id BIGINT NOT NULL); SET @restore_connection = CONNECTION_ID(); INSERT INTO session_probe VALUES (CONNECTION_ID(), @restore_connection);" + if err := os.WriteFile(input, []byte(script), 0o600); err != nil { + t.Fatal(err) + } + if err := restoreMySQL(t.Context(), cfg, input); err != nil { + t.Fatalf("恢复必须保留会话变量:%v", err) + } + db, err := database.Open(t.Context(), cfg) + if err != nil { + t.Fatal(err) + } + defer db.Close() + var same bool + if err := db.DB.QueryRowContext(t.Context(), "SELECT id = original_id FROM session_probe").Scan(&same); err != nil || !same { + t.Fatalf("恢复过程发生连接切换:%v", err) + } + }) + for _, scenario := range []struct { + name string + idle int + }{ + {name: "without_idle_connections", idle: 0}, + {name: "with_idle_connections", idle: 1}, + } { + t.Run(scenario.name, func(t *testing.T) { + cfg := server.Database(t) + cfg.MaxIdleConns = scenario.idle + db, err := database.Open(t.Context(), cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + exec := func(statement string, args ...any) sql.Result { + t.Helper() + result, err := db.DB.ExecContext(t.Context(), statement, args...) + if err != nil { + t.Fatal(err) + } + return result + } + // 子表排在父表前面,恢复需要在同一会话内暂时关闭外键检查。 + exec("CREATE TABLE z_parent (id BIGINT PRIMARY KEY AUTO_INCREMENT, label VARCHAR(100) NOT NULL) ENGINE=InnoDB") + exec("CREATE TABLE a_child (id BIGINT PRIMARY KEY, parent_id BIGINT NOT NULL, payload BLOB, amount DECIMAL(20,6), optional_value TEXT NULL, created_at DATETIME(6), FOREIGN KEY (parent_id) REFERENCES z_parent(id)) ENGINE=InnoDB") + label := "证书恢复;单引号'与反斜线\\\n" + payload := []byte{0, 1, 39, 59, 92, 255} + exec("INSERT INTO z_parent (id, label) VALUES (7, ?)", label) + exec("INSERT INTO a_child VALUES (1, 7, ?, 123456789.123456, NULL, '2026-09-27 12:34:56.123456')", payload) + root := filepath.Join(server.Root(), scenario.name) + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + backup := filepath.Join(root, "backup.sql") + if err := backupMySQLDatabase(t.Context(), cfg, backup); err != nil { + t.Fatal(err) + } + exec("DELETE FROM a_child") + exec("DELETE FROM z_parent") + exec("INSERT INTO z_parent (id, label) VALUES (8, 'changed')") + exec("INSERT INTO a_child (id, parent_id) VALUES (2, 8)") + if err := restoreMySQL(t.Context(), cfg, backup); err != nil { + t.Fatalf("恢复真实备份失败:%v", err) + } + var gotLabel, amount, created string + var gotPayload []byte + var optional sql.NullString + if err := db.DB.QueryRowContext(t.Context(), "SELECT p.label, c.payload, CAST(c.amount AS CHAR), c.optional_value, DATE_FORMAT(c.created_at, '%Y-%m-%d %H:%i:%s.%f') FROM a_child c JOIN z_parent p ON p.id = c.parent_id WHERE c.id = 1 AND p.id = 7").Scan(&gotLabel, &gotPayload, &amount, &optional, &created); err != nil { + t.Fatal(err) + } + if gotLabel != label || !bytes.Equal(gotPayload, payload) || amount != "123456789.123456" || optional.Valid || created != "2026-09-27 12:34:56.123456" { + t.Fatal("恢复后的文本、二进制、精确数值、空值或时间不一致") + } + id, err := exec("INSERT INTO z_parent (label) VALUES ('next')").LastInsertId() + if err != nil || id != 8 { + t.Fatalf("恢复后的自增序列不正确:%d,%v", id, err) + } + if _, err := db.DB.ExecContext(t.Context(), "INSERT INTO a_child (id, parent_id) VALUES (99, 999)"); err == nil { + t.Fatal("恢复后仍须强制检查外键约束") + } + + t.Run("stop_on_error", func(t *testing.T) { + input := filepath.Join(root, "invalid.sql") + // 第一条写入已经成功时,不能隐式重放或继续执行失败后的语句。 + script := "SET FOREIGN_KEY_CHECKS=0; INSERT INTO z_parent (id, label) VALUES (20, 'before'); INSERT INTO absent_table VALUES (1); INSERT INTO z_parent (id, label) VALUES (21, 'after');" + if err := os.WriteFile(input, []byte(script), 0o600); err != nil { + t.Fatal(err) + } + if err := restoreMySQL(t.Context(), cfg, input); err == nil { + t.Fatal("无效恢复语句应返回错误") + } + var count int + if err := db.DB.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM z_parent WHERE id IN (20, 21)").Scan(&count); err != nil || count != 1 { + t.Fatalf("失败后不应继续执行或隐式重放:%d,%v", count, err) + } + }) + t.Run("cancel_before_restore", func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if err := restoreMySQL(ctx, cfg, backup); err == nil { + t.Fatal("已取消的恢复不得执行") + } + var count int + if err := db.DB.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM z_parent WHERE id = 20").Scan(&count); err != nil || count != 1 { + t.Fatalf("取消恢复不应修改数据:%d,%v", count, err) + } + }) + }) + } +}