Smail/server/services/dbmigration/sql_executor_test.go
smail 70a2f39042
Some checks are pending
Security Checks / security (push) Waiting to run
完善配置快照预检与数据库迁移失败处理
2026-08-30 21:34:23 +08:00

320 lines
11 KiB
Go

package dbmigration
import (
"context"
"errors"
"net/url"
"strings"
"testing"
"git.pcux.cn/xuans/Smail/config"
mysql "github.com/go-sql-driver/mysql"
"github.com/lib/pq"
)
type recordingStructuredCopier struct {
called bool
source config.DatabaseConfig
target config.DatabaseConfig
err error
}
func (c *recordingStructuredCopier) Copy(_ context.Context, source, target config.DatabaseConfig) error {
c.called = true
c.source = source
c.target = target
return c.err
}
func validStructuredPlan(t *testing.T) Plan {
t.Helper()
database := testDatabase(t)
database.Name = "smail"
target := database
target.Name = "smail_next"
return Plan{
sourceConfig: database,
targetConfig: target,
actor: config.AuditActor{UserID: "admin-1", Role: "super_admin", SessionID: "session-1"},
}
}
func TestSQLExecutorCopiesAndSwitchesWithOrderedStatuses(t *testing.T) {
copier := &recordingStructuredCopier{}
var actor config.AuditActor
var generation uint64
executor := NewSQLExecutor(copier, func(_ context.Context, receivedGeneration uint64, _ config.DatabaseConfig, received config.AuditActor) error {
generation = receivedGeneration
actor = received
return nil
})
statuses := make([]Status, 0, 2)
plan := validStructuredPlan(t)
plan.expectedGeneration = 42
if err := executor.Run(context.Background(), plan, func(status Status) error {
statuses = append(statuses, status)
return nil
}); err != nil {
t.Fatal(err)
}
if len(statuses) != 2 || statuses[0] != StatusVerifying || statuses[1] != StatusSwitching {
t.Fatalf("statuses = %v, want verifying then switching", statuses)
}
if !copier.called || copier.source.Name != "smail" || copier.target.Name != "smail_next" {
t.Fatalf("copier call = %+v", copier)
}
if actor != plan.actor {
t.Fatalf("switch actor = %+v, want %+v", actor, plan.actor)
}
if generation != plan.expectedGeneration {
t.Fatalf("switch generation = %d, want %d", generation, plan.expectedGeneration)
}
}
func TestSQLExecutorRejectsImmutablePlanBeforeCopy(t *testing.T) {
copier := &recordingStructuredCopier{}
executor := NewSQLExecutor(copier, func(context.Context, uint64, config.DatabaseConfig, config.AuditActor) error { return nil })
plan := validStructuredPlan(t)
plan.targetConfig.Host = "other.example"
if err := executor.Run(context.Background(), plan, func(Status) error { return nil }); !errors.Is(err, ErrImmutableSetting) {
t.Fatalf("immutable plan error = %v, want immutable setting", err)
}
if copier.called {
t.Fatal("copier ran for immutable plan")
}
}
func TestSQLExecutorPreservesCopierError(t *testing.T) {
copyErr := errors.New("复制失败")
copier := &recordingStructuredCopier{err: copyErr}
executor := NewSQLExecutor(copier, func(context.Context, uint64, config.DatabaseConfig, config.AuditActor) error { return nil })
if err := executor.Run(context.Background(), validStructuredPlan(t), func(Status) error { return nil }); !errors.Is(err, copyErr) {
t.Fatalf("copy error = %v, want copier error", err)
}
}
func TestSQLExecutorCleansStructuredTargetWhenCopyFails(t *testing.T) {
copier := &cleanupRecordingCopier{copyErr: errors.New("复制失败")}
executor := NewSQLExecutor(copier, func(context.Context, uint64, config.DatabaseConfig, config.AuditActor) error {
return nil
})
if err := executor.Run(context.Background(), validStructuredPlan(t), func(Status) error { return nil }); !errors.Is(err, copier.copyErr) {
t.Fatalf("copy error = %v, want %v", err, copier.copyErr)
}
if !copier.cleanupCalled {
t.Fatal("structured target cleanup was not requested after copy failure")
}
}
func TestStructuredDatabaseNameValidation(t *testing.T) {
for _, value := range []string{"smail", "Smail_2026", "mail-prod"} {
if !validStructuredDatabaseName(value) {
t.Fatalf("valid database name rejected: %q", value)
}
}
for _, value := range []string{"", "smail;drop", "smail name", "../smail"} {
if validStructuredDatabaseName(value) {
t.Fatalf("unsafe database name accepted: %q", value)
}
}
}
func TestDatabaseExecutorRoutesByDatabaseType(t *testing.T) {
var sqliteCalled, structuredCalled bool
sqlite := runnerFunc(func(context.Context, Plan, func(Status) error) error {
sqliteCalled = true
return nil
})
structured := runnerFunc(func(context.Context, Plan, func(Status) error) error {
structuredCalled = true
return nil
})
router := NewDatabaseExecutor(sqlite, structured)
if err := router.Run(context.Background(), Plan{sourceConfig: config.DatabaseConfig{Type: config.DatabaseTypeSQLite}}, func(Status) error { return nil }); err != nil {
t.Fatal(err)
}
if err := router.Run(context.Background(), Plan{sourceConfig: config.DatabaseConfig{Type: config.DatabaseTypeMySQL}}, func(Status) error { return nil }); err != nil {
t.Fatal(err)
}
if !sqliteCalled || !structuredCalled {
t.Fatalf("router calls: sqlite=%v structured=%v", sqliteCalled, structuredCalled)
}
}
type runnerFunc func(context.Context, Plan, func(Status) error) error
func (f runnerFunc) Run(ctx context.Context, plan Plan, update func(Status) error) error {
return f(ctx, plan, update)
}
func TestSQLExecutorRejectsMissingConfigurationSwitchBeforeCopy(t *testing.T) {
copier := &recordingStructuredCopier{}
executor := NewSQLExecutor(copier, nil)
if err := executor.Run(context.Background(), validStructuredPlan(t), func(Status) error { return nil }); !errors.Is(err, ErrSwitchUnavailable) {
t.Fatalf("missing switch error = %v, want switch unavailable", err)
}
if copier.called {
t.Fatal("copier ran without a configuration switch")
}
}
type cleanupRecordingCopier struct {
cleanupCalled bool
completeCalled bool
copyErr error
}
func (c *cleanupRecordingCopier) Copy(context.Context, config.DatabaseConfig, config.DatabaseConfig) error {
return c.copyErr
}
func TestDatabaseAlreadyExistsErrorClassification(t *testing.T) {
mysqlError := &mysql.MySQLError{Number: 1007}
if !isDatabaseAlreadyExistsError(mysqlError, config.DatabaseTypeMySQL) {
t.Fatal("mysql duplicate database error was not classified")
}
postgresError := &pq.Error{Code: "42P04"}
if !isDatabaseAlreadyExistsError(postgresError, config.DatabaseTypePostgreSQL) {
t.Fatal("postgresql duplicate database error was not classified")
}
if isDatabaseAlreadyExistsError(errors.New("duplicate database"), config.DatabaseTypeMySQL) {
t.Fatal("generic mysql error was incorrectly classified")
}
}
func (c *cleanupRecordingCopier) Cleanup(context.Context, config.DatabaseConfig, config.DatabaseConfig) error {
c.cleanupCalled = true
return nil
}
func (c *cleanupRecordingCopier) Complete(config.DatabaseConfig, config.DatabaseConfig) {
c.completeCalled = true
}
func TestSQLExecutorCleansTargetForKnownConfigurationSwitchRejection(t *testing.T) {
copier := &cleanupRecordingCopier{}
executor := NewSQLExecutor(copier, func(context.Context, uint64, config.DatabaseConfig, config.AuditActor) error {
return config.ErrGenerationConflict
})
err := executor.Run(context.Background(), validStructuredPlan(t), func(Status) error { return nil })
if !errors.Is(err, config.ErrGenerationConflict) || errors.Is(err, ErrSwitchOutcomeUnknown) {
t.Fatalf("known switch rejection = %v", err)
}
if !copier.cleanupCalled {
t.Fatal("structured target was not cleaned after known switch rejection")
}
}
func TestSQLExecutorPreservesStructuredTargetWhenConfigurationSwitchOutcomeIsUnknown(t *testing.T) {
copier := &cleanupRecordingCopier{}
executor := NewSQLExecutor(copier, func(context.Context, uint64, config.DatabaseConfig, config.AuditActor) error {
return errors.New("配置切换失败")
})
if err := executor.Run(context.Background(), validStructuredPlan(t), func(Status) error { return nil }); !errors.Is(err, ErrSwitchOutcomeUnknown) {
t.Fatal("configuration switch failure was not returned")
}
if copier.cleanupCalled {
t.Fatal("structured target was cleaned up even though switch outcome was unknown")
}
if copier.completeCalled {
t.Fatal("structured target was finalized after a failed switch")
}
}
func TestSQLExecutorCleansStructuredTargetWhenStatusUpdateFails(t *testing.T) {
copier := &cleanupRecordingCopier{}
executor := NewSQLExecutor(copier, func(context.Context, uint64, config.DatabaseConfig, config.AuditActor) error {
return nil
})
statusError := errors.New("状态写入失败")
if err := executor.Run(context.Background(), validStructuredPlan(t), func(status Status) error {
if status == StatusSwitching {
return statusError
}
return nil
}); !errors.Is(err, statusError) {
t.Fatalf("status update error = %v, want %v", err, statusError)
}
if !copier.cleanupCalled {
t.Fatal("structured target cleanup was not requested after status update failure")
}
}
func TestSQLExecutorCleansStructuredTargetWhenContextIsCanceledAfterCopy(t *testing.T) {
copier := &cleanupRecordingCopier{}
executor := NewSQLExecutor(copier, func(context.Context, uint64, config.DatabaseConfig, config.AuditActor) error {
return nil
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := executor.Run(ctx, validStructuredPlan(t), func(status Status) error {
if status == StatusVerifying {
cancel()
}
return nil
}); !errors.Is(err, context.Canceled) {
t.Fatalf("canceled execution error = %v, want context canceled", err)
}
if !copier.cleanupCalled {
t.Fatal("structured target cleanup was not requested after cancellation")
}
}
func TestRuntimeSQLDSNSupportsIPv6AndEscapesCredentials(t *testing.T) {
database := config.DatabaseConfig{
Type: config.DatabaseTypeMySQL,
Host: "2001:db8::3",
Port: 3306,
Username: "mail user",
}
driver, dsn, err := runtimeSQLDSN(database, "p@ss/word", "smail_next")
if err != nil {
t.Fatal(err)
}
if driver != "mysql" {
t.Fatalf("mysql driver = %q", driver)
}
parsedMySQL, err := mysql.ParseDSN(dsn)
if err != nil {
t.Fatal(err)
}
if parsedMySQL.Addr != "[2001:db8::3]:3306" || parsedMySQL.User != "mail user" || parsedMySQL.Passwd != "p@ss/word" || parsedMySQL.DBName != "smail_next" {
t.Fatalf("mysql dsn config = %+v", parsedMySQL)
}
database.Type = config.DatabaseTypePostgreSQL
driver, dsn, err = runtimeSQLDSN(database, "p@ss/word", "smail_next")
if err != nil {
t.Fatal(err)
}
if driver != "postgres" {
t.Fatalf("postgres driver = %q", driver)
}
parsedPostgres, err := url.Parse(dsn)
if err != nil {
t.Fatal(err)
}
password, ok := parsedPostgres.User.Password()
if !ok || parsedPostgres.Host != "[2001:db8::3]:3306" || parsedPostgres.User.Username() != "mail user" || password != "p@ss/word" || parsedPostgres.Path != "/smail_next" {
t.Fatalf("postgres dsn = %s", dsn)
}
}
func TestMySQLReferenceRulesAndIdentifiersAreRestrictedSafely(t *testing.T) {
for _, rule := range []string{"CASCADE", "SET NULL", "SET DEFAULT", "RESTRICT", "NO ACTION"} {
if !validMySQLReferenceRule(rule) {
t.Fatalf("valid mysql reference rule rejected: %q", rule)
}
}
for _, rule := range []string{"DROP DATABASE", "CASCADE; DROP TABLE users"} {
if validMySQLReferenceRule(rule) {
t.Fatalf("unsafe mysql reference rule accepted: %q", rule)
}
}
if got := quoteMySQLIdentifier("mail`prod"); got != "`mail``prod`" {
t.Fatalf("quoted mysql identifier = %q", got)
}
if got := strings.Join(quoteMySQLIdentifiers([]string{"user", "tenant"}), ", "); got != "`user`, `tenant`" {
t.Fatalf("quoted mysql identifiers = %q", got)
}
}