Scert/backend/internal/cli/mysql_commands_integration_test.go
xuans 7bfd400345 修复MySQL恢复会话切换与精确数值备份错误
固定恢复连接,按DECIMAL列类型保留精确文本。新增双版本实库备份恢复、错误停止、取消以及正式命令失败回滚回归;不将同进程命令测试视为独立发行验收。
2026-09-27 19:32:46 +08:00

199 lines
7.2 KiB
Go

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("必须保留恢复前的数据库快照")
}
})
}
}