Some checks are pending
Security Checks / security (push) Waiting to run
统一 frontend、backend、test、test-log 和 out 目录,更新构建、部署及测试入口;纳入配置管理兼容与首次安全引导修复;排除本机配置、密钥和测试产物。
530 lines
17 KiB
Go
530 lines
17 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
|
|
})
|
|
source := testDatabase(t)
|
|
target := source
|
|
target.Name = "smail_next"
|
|
if err := copier.Copy(context.Background(), source, target); !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 TestValidateSQLCopyRequestRejectsUnsafeOrCrossConnectionTargets(t *testing.T) {
|
|
source := testDatabase(t)
|
|
validTarget := source
|
|
validTarget.Name = "smail_next"
|
|
if err := validateSQLCopyRequest(source, validTarget); err != nil {
|
|
t.Fatalf("valid copy request rejected: %v", err)
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
mutate func(*config.DatabaseConfig, *config.DatabaseConfig)
|
|
want error
|
|
}{
|
|
{
|
|
name: "same database",
|
|
mutate: func(_, target *config.DatabaseConfig) {
|
|
target.Name = "smail"
|
|
},
|
|
want: ErrImmutableSetting,
|
|
},
|
|
{
|
|
name: "unsafe target name",
|
|
mutate: func(_, target *config.DatabaseConfig) {
|
|
target.Name = "smail;drop"
|
|
},
|
|
want: ErrInvalidTargetName,
|
|
},
|
|
{
|
|
name: "different host",
|
|
mutate: func(_, target *config.DatabaseConfig) {
|
|
target.Host = "other.internal"
|
|
},
|
|
want: ErrImmutableSetting,
|
|
},
|
|
{
|
|
name: "unsupported type",
|
|
mutate: func(_, target *config.DatabaseConfig) {
|
|
target.Type = config.DatabaseTypeSQLite
|
|
},
|
|
want: ErrUnsupportedDatabase,
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
candidate := validTarget
|
|
test.mutate(&source, &candidate)
|
|
if err := validateSQLCopyRequest(source, candidate); !errors.Is(err, test.want) {
|
|
t.Fatalf("validation error = %v, want %v", err, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestStructuredTableComparisonsRequireExactSets(t *testing.T) {
|
|
if !sameStringSet([]string{"users", "messages"}, []string{"messages", "users"}) {
|
|
t.Fatal("same string set rejected equal unordered values")
|
|
}
|
|
if sameStringSet([]string{"users"}, []string{"users", "messages"}) {
|
|
t.Fatal("same string set accepted different lengths")
|
|
}
|
|
if sameStringSet([]string{"users"}, []string{"accounts"}) {
|
|
t.Fatal("same string set accepted different values")
|
|
}
|
|
|
|
source := []postgreSQLTable{{schema: "public", name: "messages"}}
|
|
if !samePostgreSQLTables(source, []postgreSQLTable{{schema: "public", name: "messages"}}) {
|
|
t.Fatal("same postgres table set rejected equal values")
|
|
}
|
|
if samePostgreSQLTables(source, []postgreSQLTable{{schema: "archive", name: "messages"}}) {
|
|
t.Fatal("same postgres table set accepted different schemas")
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestMySQLForeignKeyComparisonsNormalizeDatabaseReferences(t *testing.T) {
|
|
source := []mysqlForeignKey{
|
|
{
|
|
tableName: "messages",
|
|
constraintName: "fk_messages_user",
|
|
columns: []string{"user_id"},
|
|
referencedSchema: "smail",
|
|
referencedTable: "users",
|
|
referencedColumns: []string{"id"},
|
|
updateRule: "NO ACTION",
|
|
deleteRule: "CASCADE",
|
|
},
|
|
{
|
|
tableName: "user_aliases",
|
|
constraintName: "fk_aliases_user",
|
|
columns: []string{"user_id", "tenant_id"},
|
|
referencedSchema: "directory",
|
|
referencedTable: "users",
|
|
referencedColumns: []string{"id", "tenant_id"},
|
|
updateRule: "RESTRICT",
|
|
deleteRule: "SET NULL",
|
|
},
|
|
}
|
|
target := []mysqlForeignKey{
|
|
source[1],
|
|
{
|
|
tableName: "messages",
|
|
constraintName: "fk_messages_user",
|
|
columns: []string{"user_id"},
|
|
referencedSchema: "smail_next",
|
|
referencedTable: "users",
|
|
referencedColumns: []string{"id"},
|
|
updateRule: "no action",
|
|
deleteRule: "cascade",
|
|
},
|
|
}
|
|
if !sameMySQLForeignKeys(source, target, "smail", "smail_next") {
|
|
t.Fatal("foreign key comparison rejected equivalent internal and external references")
|
|
}
|
|
}
|
|
|
|
func TestMySQLForeignKeyComparisonsRejectStructuralDifferences(t *testing.T) {
|
|
base := []mysqlForeignKey{{
|
|
tableName: "messages",
|
|
constraintName: "fk_messages_user",
|
|
columns: []string{"user_id"},
|
|
referencedSchema: "smail_next",
|
|
referencedTable: "users",
|
|
referencedColumns: []string{"id"},
|
|
updateRule: "NO ACTION",
|
|
deleteRule: "CASCADE",
|
|
}}
|
|
tests := []struct {
|
|
name string
|
|
mutate func(*mysqlForeignKey)
|
|
}{
|
|
{
|
|
name: "column order",
|
|
mutate: func(value *mysqlForeignKey) {
|
|
value.columns = []string{"tenant_id"}
|
|
},
|
|
},
|
|
{
|
|
name: "referenced table",
|
|
mutate: func(value *mysqlForeignKey) {
|
|
value.referencedTable = "accounts"
|
|
},
|
|
},
|
|
{
|
|
name: "update rule",
|
|
mutate: func(value *mysqlForeignKey) {
|
|
value.updateRule = "CASCADE"
|
|
},
|
|
},
|
|
{
|
|
name: "delete rule",
|
|
mutate: func(value *mysqlForeignKey) {
|
|
value.deleteRule = "RESTRICT"
|
|
},
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
candidate := base[0]
|
|
test.mutate(&candidate)
|
|
if sameMySQLForeignKeys(base, []mysqlForeignKey{candidate}, "smail", "smail_next") {
|
|
t.Fatalf("foreign key comparison accepted %s difference", test.name)
|
|
}
|
|
})
|
|
}
|
|
|
|
if sameMySQLForeignKeys(base, nil, "smail", "smail_next") {
|
|
t.Fatal("foreign key comparison accepted a missing foreign key")
|
|
}
|
|
extra := append(append([]mysqlForeignKey(nil), base...), mysqlForeignKey{
|
|
tableName: "audit",
|
|
constraintName: "fk_audit_user",
|
|
columns: []string{"user_id"},
|
|
referencedSchema: "smail_next",
|
|
referencedTable: "users",
|
|
referencedColumns: []string{"id"},
|
|
updateRule: "NO ACTION",
|
|
deleteRule: "CASCADE",
|
|
})
|
|
if sameMySQLForeignKeys(base, extra, "smail", "smail_next") {
|
|
t.Fatal("foreign key comparison accepted an extra foreign key")
|
|
}
|
|
}
|