Files
kra-new/internal/data/swmigration/backup_test.go
T

156 lines
4.8 KiB
Go

package swmigration
import (
"compress/gzip"
"context"
"database/sql"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
driver "github.com/go-sql-driver/mysql"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
assets "kra/deploy/migrations"
)
func backupTestDB(t *testing.T) *gorm.DB {
t.Helper()
dsn := os.Getenv("SW_TEST_MYSQL_DSN")
if dsn == "" {
t.Skip("isolated MySQL required")
}
cfg, err := driver.ParseDSN(dsn)
if err != nil {
t.Fatal(err)
}
if cfg.Net != "tcp" || !strings.HasPrefix(cfg.Addr, "127.0.0.1:") {
t.Fatal("local MySQL required")
}
cfg.DBName = ""
cfg.ParseTime = true
admin, err := sql.Open("mysql", cfg.FormatDSN())
if err != nil {
t.Fatal(err)
}
name := fmt.Sprintf("sw_test_backup_%d", time.Now().UnixNano())
if _, err := admin.Exec("CREATE DATABASE `" + name + "` CHARACTER SET utf8mb4"); err != nil {
t.Fatal(err)
}
cfg.DBName = name
db, err := gorm.Open(mysql.Open(cfg.FormatDSN()), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatal(err)
}
pool, _ := db.DB()
pool.SetMaxOpenConns(1)
t.Cleanup(func() {
pool.Close()
_, err := admin.Exec("DROP DATABASE `" + name + "`")
if err != nil {
t.Error(err)
}
admin.Close()
})
return db
}
func TestMySQLBackupRoundTripPreservesStorageValues(t *testing.T) {
source, target := backupTestDB(t), backupTestDB(t)
for _, statement := range []string{
"SET SESSION time_zone='+08:00'",
"CREATE TABLE sys_fixture(id BIGINT UNSIGNED PRIMARY KEY,txt LONGTEXT,raw VARBINARY(16),amount DECIMAL(30,6),payload JSON,flags BIT(9),stamp TIMESTAMP(6) NULL,clock DATETIME(6),computed DECIMAL(31,6) GENERATED ALWAYS AS(amount*2) VIRTUAL)",
"INSERT INTO sys_fixture(id,txt,raw,amount,payload,flags,stamp,clock) VALUES(18446744073709551615,'中文😀 quotes '' slash \\\\ line',X'00FF27225C','123456789012345678901234.123456','{\"id\":\"9007199254740993\"}',b'100000001','2026-02-03 04:05:06.123456','2026-02-03 04:05:06.123456'),(1,'',X'',NULL,NULL,b'0',NULL,NULL)",
"CREATE TABLE tab_user(id INT PRIMARY KEY)", "INSERT INTO tab_user VALUES(1)",
"CREATE TABLE tab_promote(id BIGINT PRIMARY KEY)", "INSERT INTO tab_promote VALUES(9007199254740993)",
} {
if err := source.Exec(statement).Error; err != nil {
t.Fatal(err)
}
}
if err := target.Exec("CREATE TABLE tab_user(id INT PRIMARY KEY)").Error; err != nil {
t.Fatal(err)
}
target.Exec("INSERT INTO tab_user VALUES(77)")
root := t.TempDir()
path, digest, err := backupDatabase(context.Background(), source, root, "test-db", "source", nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
if err := verifyBackup(path, digest); err != nil {
t.Fatal(err)
}
file, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
reader, err := gzip.NewReader(file)
if err != nil {
t.Fatal(err)
}
raw, err := io.ReadAll(reader)
reader.Close()
file.Close()
if err != nil {
t.Fatal(err)
}
statements, err := assets.Statements(string(raw))
if err != nil {
t.Fatal(err)
}
for _, statement := range statements {
if err := target.Exec(statement).Error; err != nil {
t.Fatal(err)
}
}
query := "SELECT CAST(id AS CHAR) AS id,txt,HEX(raw) AS raw,CAST(amount AS CHAR) AS amount,CAST(payload AS CHAR) AS payload,CAST(flags AS UNSIGNED) AS flags,UNIX_TIMESTAMP(stamp) AS stamp,CAST(clock AS CHAR) AS clock,CAST(computed AS CHAR) AS computed FROM sys_fixture ORDER BY id"
var before, after []map[string]any
if err := source.Raw(query).Scan(&before).Error; err != nil {
t.Fatal(err)
}
if err := target.Raw(query).Scan(&after).Error; err != nil {
t.Fatal(err)
}
if fmt.Sprint(before) != fmt.Sprint(after) {
t.Fatalf("SQL backup round trip changed storage values: before=%v after=%v", before, after)
}
var id uint64
target.Table("tab_promote").Select("id").Scan(&id)
if id != 9007199254740993 {
t.Fatal("large promote ID changed")
}
target.Table("tab_user").Select("id").Scan(&id)
if id != 77 {
t.Fatal("schema-only raw table was overwritten")
}
var timezone string
source.Raw("SELECT @@SESSION.time_zone").Scan(&timezone)
if timezone != "+08:00" {
t.Fatalf("backup leaked connection time zone: %s", timezone)
}
appendFile, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0600)
if err != nil {
t.Fatal(err)
}
appendFile.WriteString("corrupt")
appendFile.Close()
if err := verifyBackup(path, digest); err == nil {
t.Fatal("modified backup checksum accepted")
}
cancelled, cancel := context.WithCancel(context.Background())
cancel()
if _, _, err := backupDatabase(cancelled, source, root, "test-db", "cancelled", nil, slog.New(slog.NewTextHandler(io.Discard, nil))); err == nil {
t.Fatal("cancelled backup succeeded")
}
partial, _ := filepath.Glob(filepath.Join(root, "*.partial"))
if len(partial) != 0 {
t.Fatalf("partial backup left behind: %v", partial)
}
}