Smail/backend/services/dbmigration/portable_integration_test.go
smail f265cc62f5
Some checks are pending
Security Checks / security (push) Waiting to run
补充云端验收证据并加固测试凭据检查
2026-09-21 00:21:50 +08:00

137 lines
5.7 KiB
Go

package dbmigration
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"os"
"strings"
"testing"
"time"
"git.pcux.cn/xuans/Smail/config"
)
// 集成测试只创建随机命名的新库,不修改现有业务库,也不自动删除测试库。
// 必须显式提供专用测试服务连接;缺少连接时跳过,不冒充实库验收成功。
func TestPortableSQLIntegration(t *testing.T) {
for _, item := range []struct {
kind config.DatabaseType
env string
}{
{config.DatabaseTypeMySQL, "SMAIL_TEST_MIGRATION_MYSQL"},
{config.DatabaseTypePostgreSQL, "SMAIL_TEST_MIGRATION_POSTGRES"},
} {
t.Run(string(item.kind), func(t *testing.T) {
input := portableIntegrationInput(t, item.env, item.kind)
runPortableIntegrationChain(t, []TargetInput{input, input})
})
}
t.Run("mysql_postgresql", func(t *testing.T) {
mysql := portableIntegrationInput(t, "SMAIL_TEST_MIGRATION_MYSQL", config.DatabaseTypeMySQL)
postgres := portableIntegrationInput(t, "SMAIL_TEST_MIGRATION_POSTGRES", config.DatabaseTypePostgreSQL)
runPortableIntegrationChain(t, []TargetInput{mysql, postgres, mysql})
})
}
func portableIntegrationInput(t *testing.T, name string, kind config.DatabaseType) TargetInput {
t.Helper()
raw := strings.TrimSpace(os.Getenv(name))
if raw == "" {
t.Skip(name + " is not configured")
}
var input TargetInput
decoder := json.NewDecoder(strings.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&input); err != nil || input.Type != kind || input.Name != "" || input.LocalRef != "" {
t.Fatal("integration connection must specify the expected type without a database name or local path")
}
return input
}
func runPortableIntegrationChain(t *testing.T, inputs []TargetInput) {
t.Helper()
root, source := portableFixture(t)
manager := portableIntegrationManager(t, root, source)
open, err := NewRuntimeSQLConnectionFactory(root)
if err != nil {
t.Fatal("integration connection factory unavailable")
}
// 使用二进制历史记录检查跨库复制,不输出配置原文或凭据。
payload := []byte{0, 1, 127, 128, 255}
digest := fmt.Sprintf("%x", sha256.Sum256(payload))
seed := &PortableExecutor{root: root, open: open}
database, err := seed.connect(context.Background(), source)
if err != nil {
t.Fatal("integration history source unavailable")
}
_, err = database.Exec("INSERT INTO config_records(kind,id,payload,digest) VALUES ('integration','integration-history',?,?)", payload, digest)
_ = database.Close()
if err != nil {
t.Fatal("integration history seed failed")
}
inputs = append(inputs, TargetInput{Type: config.DatabaseTypeSQLite, LocalRef: "roundtrip.db"})
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
for index, input := range inputs {
if input.Type != config.DatabaseTypeSQLite {
input.Name, err = randomID("smail_migration_test_")
if err != nil {
t.Fatal("integration name generation failed")
}
}
target, err := input.Prepare(root)
if err != nil {
t.Fatal("integration connection configuration is invalid")
}
plan, err := buildTargetPlan(source, target)
if err != nil {
t.Fatal("integration plan validation failed")
}
plan.expectedGeneration = manager.Snapshot().Generation
plan.actor = config.AuditActor{UserID: "40", Role: "super_admin", SessionID: "integration-session", RecentAuth: true, RecoveryCodeConfigured: true}
executor := &PortableExecutor{root: root, open: open, switchConfig: manager.SwitchDatabase}
// 路径只记录随机测试库名,不记录连接账号、密码或底层驱动错误。
t.Logf("hop %d: %s -> %s, retained database: %s%s", index+1, source.Type, target.Type, target.Name, target.LocalRef)
if err := executor.Run(ctx, plan, func(Status) error { return nil }); err != nil {
executor.Close()
t.Fatalf("migration failed: %s", migrationErrorCode(err))
}
executor.Close()
if status := manager.Status(); len(status.PendingRestart) != 1 || status.PendingRestart[0] != config.ServiceDatabase {
t.Fatal("database restart requirement was not recorded")
}
manager = reopenPortableManager(t, root)
if snapshot := manager.Snapshot(); snapshot.Generation != plan.expectedGeneration+1 || snapshot.Root.Database != target {
t.Fatal("configuration transaction did not survive manager restart")
}
connection, err := executor.connect(ctx, target)
if err != nil {
t.Fatal("migrated target could not be reopened")
}
var subject string
queryErr := connection.QueryRowContext(ctx, "SELECT subject FROM email WHERE id=82").Scan(&subject)
var logPayload string
logErr := connection.QueryRowContext(ctx, "SELECT payload FROM mail_log WHERE id='migration-log'").Scan(&logPayload)
var historyPayload []byte
var historyDigest string
historyErr := connection.QueryRowContext(ctx, "SELECT payload,digest FROM config_records WHERE kind='integration' AND id='integration-history'").Scan(&historyPayload, &historyDigest)
var backups int
backupErr := connection.QueryRowContext(ctx, "SELECT COUNT(*) FROM config_records WHERE kind='backup'").Scan(&backups)
_, insertErr := connection.ExecContext(ctx, "INSERT INTO "+quoteDatabaseIdentifier(target.Type, "user")+" (account,name,password) VALUES ('next_hop_"+string(rune('a'+index))+"','sequence','test-only')")
_ = connection.Close()
if queryErr != nil || subject != "中文主题" || logErr != nil || logPayload != `{"message":"中文日志"}` || insertErr != nil {
t.Fatal("target content or identity sequence was not preserved")
}
if historyErr != nil || !bytes.Equal(historyPayload, payload) || historyDigest != digest {
t.Fatal("configuration history bytes or digest were not preserved")
}
if backupErr != nil || backups == 0 {
t.Fatal("real configuration backup did not survive migration")
}
source = target
}
}