Scert/backend/internal/testsupport/mysqltest/tools.go
xuans d95ecfa9a0 完善MySQL官方客户端错误码对照测试支持
仅提取数据库错误码,不输出官方客户端原始错误内容;保留严格TLS连接和凭据隔离。CLI常规测试通过,视图业务改动另行复核。
2026-09-28 00:20:15 +08:00

147 lines
5.0 KiB
Go
Raw Permalink 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 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")
}