package mysqltest import ( "bytes" "context" "io" "os" "os/exec" "path/filepath" "regexp" "runtime" "strconv" "strings" "testing" "time" "github.com/go-sql-driver/mysql" "git.pcux.cn/xuans/Scert/backend/internal/config" ) // 官方工具仅连接本夹具创建的隔离实例,密码只传入子进程环境,不进入命令行或文件。 func (f *Server) Dump(t *testing.T, cfg config.DatabaseConfig, output string) { f.dump(t, cfg, output, nil) } func (f *Server) DumpStoredPrograms(t *testing.T, cfg config.DatabaseConfig, output string) { f.dump(t, cfg, output, []string{"--routines", "--events"}) } func (f *Server) dump(t *testing.T, cfg config.DatabaseConfig, output string, extra []string) { t.Helper() file, err := os.OpenFile(output, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) if err != nil { t.Fatal(err) } defer file.Close() args := []string{ "--single-transaction", "--no-tablespaces", "--set-gtid-purged=OFF", "--skip-add-locks", "--hex-blob", "--complete-insert", "--skip-comments", "--skip-extended-insert", } f.runTool(t, "mysqldump", cfg, append(args, extra...), nil, file) if err := file.Close(); err != nil { t.Fatal(err) } } func (f *Server) Import(t *testing.T, cfg config.DatabaseConfig, input string) { t.Helper() file, err := os.Open(input) if err != nil { t.Fatal(err) } defer file.Close() f.runTool(t, "mysql", cfg, []string{ "--binary-mode", "--batch", "--skip-reconnect", "--local-infile=0", }, file, io.Discard) } // 仅返回官方客户端的数据库错误码,不向日志透传可能含业务数据的错误文本。 func (f *Server) ImportErrorCode(t *testing.T, cfg config.DatabaseConfig, input string) int { t.Helper() file, err := os.Open(input) if err != nil { t.Fatal(err) } defer file.Close() code, err := f.runToolResult(t, "mysql", cfg, []string{ "--binary-mode", "--batch", "--skip-reconnect", "--local-infile=0", }, file, io.Discard) if err == nil || code == 0 { t.Fatal("官方客户端未返回预期的数据库错误码,不能作为失败对照") } return code } func (f *Server) IsMySQL8() bool { return strings.Contains(f.version, "Ver 8.0.") } func (f *Server) runTool(t *testing.T, name string, cfg config.DatabaseConfig, args []string, input io.Reader, output io.Writer) { t.Helper() if _, err := f.runToolResult(t, name, cfg, args, input, output); err != nil { t.Fatalf("官方 %s 执行失败:%v;原始输出已隐藏", name, err) } } func (f *Server) runToolResult(t *testing.T, name string, cfg config.DatabaseConfig, args []string, input io.Reader, output io.Writer) (int, error) { t.Helper() parsed, err := mysql.ParseDSN(cfg.DSN) if err != nil || cfg.Driver != "mysql" { t.Fatal("官方工具测试连接无效") } if parsed.Net != "tcp" || parsed.Addr != "127.0.0.1:"+f.port || parsed.TLSConfig != f.tlsName || parsed.Passwd != f.password || !strings.HasPrefix(parsed.DBName, "scert_test_") || !strings.HasPrefix(parsed.User, "scert_user_") { t.Fatal("官方工具只允许连接本次隔离实例的测试数据库") } binary := filepath.Join(filepath.Dir(f.binary), name) if runtime.GOOS == "windows" { binary += ".exe" } ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) defer cancel() version, err := exec.CommandContext(ctx, binary, "--no-defaults", "--version").Output() if err != nil { t.Fatal("无法读取官方工具版本,不能跳过互操作验证") } expected := "5.7." if f.IsMySQL8() { expected = "8.0." } if !strings.Contains(string(version), expected) { t.Fatal("官方工具与本次测试服务端版本系列不一致") } t.Logf("官方工具:%s", strings.TrimSpace(string(version))) base := []string{ "--no-defaults", "--protocol=TCP", "--host=127.0.0.1", "--port=" + f.port, "--user=" + parsed.User, "--ssl-mode=VERIFY_IDENTITY", "--ssl-ca=" + filepath.Join(f.root, "server.crt"), "--default-character-set=utf8mb4", } command := exec.CommandContext(ctx, binary, append(append(base, args...), parsed.DBName)...) command.Env = mysqlToolTestEnvironment(f.root, parsed.Passwd) command.Stdin, command.Stdout = input, output // 错误可能含数据库内容;只报告退出状态,不透传工具的原始错误输出。 var stderr bytes.Buffer command.Stderr = &stderr command.WaitDelay = 2 * time.Second err = command.Run() code := 0 if match := regexp.MustCompile(`(?m)^ERROR ([0-9]+) \([A-Z0-9]+\)`).FindSubmatch(stderr.Bytes()); len(match) > 1 { code, _ = strconv.Atoi(string(match[1])) } return code, err } func mysqlToolTestEnvironment(root, password string) []string { env := make([]string, 0, len(os.Environ())+4) for _, entry := range os.Environ() { key, _, _ := strings.Cut(entry, "=") key = strings.ToUpper(key) if strings.HasPrefix(key, "MYSQL") || key == "LANG" || key == "LC_ALL" { continue } env = append(env, entry) } // --no-defaults 仍可能读取登录文件;指定隔离目录中不存在的文件,避免读取用户凭据。 return append(env, "MYSQL_PWD="+password, "MYSQL_TEST_LOGIN_FILE="+filepath.Join(root, "no-login.cnf"), "LANG=C", "LC_ALL=C") }