Scert/backend/internal/testsupport/mysqltest/server.go
xuans 07429adbd8 提取MySQL隔离实库测试公共组件
保留数据库测试适配接口,复用实例生命周期与严格TLS配置。后端全量测试和静态检查通过,MySQL 5.7及8.0各55个实库子测试通过。
2026-09-27 19:20:53 +08:00

262 lines
8.6 KiB
Go
Raw 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 (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"database/sql"
"encoding/hex"
"encoding/pem"
"fmt"
"math/big"
"net"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
"time"
"github.com/go-sql-driver/mysql"
"git.pcux.cn/xuans/Scert/backend/internal/config"
)
type Server struct {
root string
binary string
version string
port string
password string
tlsName string
admin *sql.DB
command *exec.Cmd
exit chan error
sequence int
}
// 只接受程序目录与产物目录;不接受现有实例连接串,也不读取机器上的默认配置。
func New(t *testing.T) *Server {
t.Helper()
bin := os.Getenv("SCERT_MYSQL_TEST_BIN")
if bin == "" {
t.Skip("未启用 MySQL 隔离实库测试")
}
root := os.Getenv("SCERT_MYSQL_TEST_ROOT")
if !filepath.IsAbs(bin) || !filepath.IsAbs(root) {
t.Fatal("MySQL 实库测试要求程序与隔离产物目录使用绝对路径")
}
binary := filepath.Join(bin, "mysqld")
if runtime.GOOS == "windows" {
binary += ".exe"
}
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
output, err := exec.CommandContext(ctx, binary, "--no-defaults", "--version").Output()
if err != nil {
t.Fatal("无法读取 MySQL 服务端版本")
}
version := string(output)
if !strings.Contains(version, "Ver 5.7.") && !strings.Contains(version, "Ver 8.0.") {
t.Fatal("隔离测试仅接受明确支持目标 MySQL 5.7 或 8.0")
}
if err := os.MkdirAll(root, 0o700); err != nil {
t.Fatal(err)
}
root, err = os.MkdirTemp(root, "scert-mysql-integration-")
if err != nil {
t.Fatal(err)
}
password := make([]byte, 32)
if _, err := rand.Read(password); err != nil {
t.Fatal(err)
}
f := &Server{root: root, binary: binary, version: version, password: hex.EncodeToString(password)}
t.Logf("独立 MySQL 程序:%s;产物保留于:%s", strings.TrimSpace(version), root)
f.tlsName = "scert-mysql-" + filepath.Base(root)
f.createTLS(t)
t.Cleanup(func() { mysql.DeregisterTLSConfig(f.tlsName) })
listener, err := net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
f.port = strconv.Itoa(listener.Addr().(*net.TCPAddr).Port)
if err := listener.Close(); err != nil {
t.Fatal(err)
}
// 初始化阶段不启动监听;首次启动在开放连接前为所有测试管理账户设随机密码。
initCtx, initCancel := context.WithTimeout(t.Context(), 2*time.Minute)
defer initCancel()
args := append(f.baseArgs(), "--initialize-insecure")
if err := exec.CommandContext(initCtx, binary, args...).Run(); err != nil {
t.Fatal("初始化独立 MySQL 失败,请检查保留目录中的服务端日志")
}
bootstrap := "ALTER USER 'root'@'localhost' IDENTIFIED BY '" + f.password + "';\n" +
"CREATE USER 'scert_harness'@'127.0.0.1' IDENTIFIED BY '" + f.password + "' REQUIRE SSL;\n" +
"GRANT ALL PRIVILEGES ON *.* TO 'scert_harness'@'127.0.0.1' WITH GRANT OPTION;\n"
f.write(t, "bootstrap.sql", []byte(bootstrap))
defer f.write(t, "bootstrap.sql", nil)
t.Cleanup(func() { f.Stop(t) })
f.Start(t, true)
// 初始口令文件不进入证据;保留空文件而非删除测试产物。
f.write(t, "bootstrap.sql", nil)
return f
}
func (f *Server) baseArgs() []string {
return []string{"--no-defaults", "--basedir=" + filepath.Dir(filepath.Dir(f.binary)),
"--datadir=" + filepath.Join(f.root, "data"), "--log-error=" + filepath.Join(f.root, "mysql.log"),
"--innodb-buffer-pool-size=32M", "--innodb-log-file-size=8M", "--performance-schema=OFF"}
}
func (f *Server) Start(t *testing.T, bootstrap bool) {
t.Helper()
args := append(f.baseArgs(), "--bind-address=127.0.0.1", "--port="+f.port,
"--skip-name-resolve", "--require-secure-transport=ON", "--max-connections=20",
"--ssl-ca="+filepath.Join(f.root, "server.crt"), "--ssl-cert="+filepath.Join(f.root, "server.crt"),
"--ssl-key="+filepath.Join(f.root, "server.key"), "--local-infile=OFF", "--secure-file-priv=NULL",
"--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci")
if strings.Contains(f.version, "Ver 8.0.") {
args = append(args, "--mysqlx=OFF")
}
if runtime.GOOS != "windows" {
args = append(args, "--socket="+filepath.Join(f.root, "mysql.sock"))
}
if bootstrap {
args = append(args, "--init-file="+filepath.Join(f.root, "bootstrap.sql"))
}
f.command = exec.Command(f.binary, args...)
if err := f.command.Start(); err != nil {
f.command = nil
t.Fatal("启动独立 MySQL 失败")
}
f.exit = make(chan error, 1)
command := f.command
go func() { f.exit <- command.Wait() }()
var err error
f.admin, err = sql.Open("mysql", f.DSN("scert_harness", ""))
if err != nil {
t.Fatal("创建独立 MySQL 管理连接失败")
}
deadline := time.Now().Add(time.Minute)
for time.Now().Before(deadline) {
select {
case <-f.exit:
f.command = nil
t.Fatal("独立 MySQL 已退出,请检查保留日志")
default:
}
ctx, cancel := context.WithTimeout(t.Context(), time.Second)
err := f.admin.PingContext(ctx)
cancel()
if err == nil {
var version string
if err := f.admin.QueryRowContext(t.Context(), "SELECT VERSION()").Scan(&version); err != nil {
t.Fatal(err)
}
t.Logf("实连版本:%s;仅监听 127.0.0.1:%s;严格 TLS", version, f.port)
return
}
time.Sleep(200 * time.Millisecond)
}
t.Fatal("独立 MySQL 未在时限内就绪,请检查保留日志;不降级 TLS")
}
func (f *Server) Stop(t *testing.T) {
t.Helper()
if f.command == nil {
if f.admin != nil {
_ = f.admin.Close()
}
return
}
if f.admin != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
_, _ = f.admin.ExecContext(ctx, "SHUTDOWN")
cancel()
_ = f.admin.Close()
}
select {
case err := <-f.exit:
if err != nil {
t.Errorf("独立 MySQL 未正常退出:%v", err)
}
case <-time.After(15 * time.Second):
_ = f.command.Process.Kill()
<-f.exit
t.Error("独立 MySQL 停止超时,已终止本测试启动的进程")
}
f.command = nil
}
func (f *Server) DSN(user, name string) string {
cfg := mysql.NewConfig()
cfg.User, cfg.Passwd = user, f.password
cfg.Net, cfg.Addr = "tcp", net.JoinHostPort("127.0.0.1", f.port)
cfg.DBName, cfg.TLSConfig = name, f.tlsName
cfg.Timeout, cfg.ReadTimeout, cfg.WriteTimeout = 3*time.Second, 10*time.Second, 10*time.Second
cfg.ParseTime = true
return cfg.FormatDSN()
}
func (f *Server) Database(t *testing.T) config.DatabaseConfig {
t.Helper()
f.sequence++
name := fmt.Sprintf("scert_test_%d", f.sequence)
user := fmt.Sprintf("scert_user_%d", f.sequence)
for _, statement := range []string{
"CREATE DATABASE " + name + " CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci",
"CREATE USER '" + user + "'@'127.0.0.1' IDENTIFIED BY '" + f.password + "' REQUIRE SSL",
"GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, DROP, REFERENCES ON " + name + ".* TO '" + user + "'@'127.0.0.1'",
} {
if _, err := f.admin.ExecContext(t.Context(), statement); err != nil {
t.Fatal("创建隔离数据库及库级账户失败")
}
}
return config.DatabaseConfig{Driver: "mysql", DSN: f.DSN(user, name), MaxOpenConns: 2, MaxIdleConns: 1}
}
func (f *Server) Root() string { return f.root }
func (f *Server) TLSName() string { return f.tlsName }
func (f *Server) Admin() *sql.DB { return f.admin }
func (f *Server) write(t *testing.T, name string, data []byte) {
t.Helper()
if err := os.WriteFile(filepath.Join(f.root, name), data, 0o600); err != nil {
t.Fatal(err)
}
}
func (f *Server) createTLS(t *testing.T) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
certificate := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "Scert isolated MySQL"},
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(24 * time.Hour), IsCA: true,
BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}}
der, err := x509.CreateCertificate(rand.Reader, certificate, certificate, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
encoded := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
f.write(t, "server.crt", encoded)
f.write(t, "server.key", pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
roots := x509.NewCertPool()
if !roots.AppendCertsFromPEM(encoded) {
t.Fatal("解析隔离测试证书失败")
}
if err := mysql.RegisterTLSConfig(f.tlsName, &tls.Config{RootCAs: roots, ServerName: "127.0.0.1", MinVersion: tls.VersionTLS12}); err != nil {
t.Fatal(err)
}
}