346 lines
12 KiB
Go
346 lines
12 KiB
Go
package dbmigration
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"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 TestSQLCopierRejectsNilDatabaseWithoutPanicking(t *testing.T) {
|
|
copier := NewSQLCopier(func(context.Context, config.DatabaseConfig, string) (*sql.DB, error) {
|
|
return nil, nil
|
|
})
|
|
if err := copier.Copy(context.Background(), testDatabase(t), testDatabase(t)); !errors.Is(err, ErrSQLConnectionUnavailable) {
|
|
t.Fatalf("nil admin database error = %v, want connection unavailable", err)
|
|
}
|
|
}
|
|
|
|
func TestSQLCopierStopsAfterAdminConnectionWhenContextIsCanceled(t *testing.T) {
|
|
openCalls := 0
|
|
copier := NewSQLCopier(func(context.Context, config.DatabaseConfig, string) (*sql.DB, error) {
|
|
openCalls++
|
|
return nil, errors.New("open should not continue")
|
|
})
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
if err := copier.Copy(ctx, testDatabase(t), testDatabase(t)); !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("canceled copy error = %v, want context canceled", err)
|
|
}
|
|
if openCalls != 0 {
|
|
t.Fatalf("open calls = %d, want zero for canceled context", openCalls)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|