Smail/backend/services/dbmigration/service_test.go
smail d6cc81ed87
Some checks are pending
Security Checks / security (push) Waiting to run
分离五项配置与运行数据并实现无损目录迁移
2026-09-08 17:16:09 +08:00

271 lines
9.0 KiB
Go

package dbmigration
import (
"context"
"database/sql"
"errors"
"os"
"path/filepath"
"testing"
"time"
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/utils/credential"
_ "modernc.org/sqlite"
)
type testRunner struct{}
func (testRunner) Run(_ context.Context, _ Plan, update func(Status) error) error {
if err := update(StatusCopying); err != nil {
return err
}
if err := update(StatusVerifying); err != nil {
return err
}
return update(StatusSwitching)
}
func testDatabase(t *testing.T) config.DatabaseConfig {
t.Helper()
vault, err := credential.NewVault(t.TempDir())
if err != nil {
t.Fatal(err)
}
ciphertext, err := vault.Encrypt("password")
if err != nil {
t.Fatal(err)
}
return config.DatabaseConfig{
Type: config.DatabaseTypeMySQL,
Host: "db.internal",
Port: 3306,
Username: "smail",
Name: "smail",
Password: config.StoredSecret{Ciphertext: ciphertext, Version: vault.Version(), UpdatedAt: time.Unix(1, 0).UTC()},
}
}
func TestPreviewAndExecuteUseOnlyDatabaseName(t *testing.T) {
service := NewService(testRunner{})
service.now = func() time.Time { return time.Date(2026, 8, 30, 0, 0, 0, 0, time.UTC) }
record, token, err := service.Preview(context.Background(), testDatabase(t), 7, "smail_next")
if err != nil {
t.Fatal(err)
}
if record.Status != StatusPreviewed || record.Source.Name != "smail" || record.Target.Name != "smail_next" {
t.Fatalf("unexpected preview: %+v", record)
}
if record.Source.Host != record.Target.Host || record.Source.Port != record.Target.Port || record.Source.Username != record.Target.Username {
t.Fatalf("immutable database settings changed: %+v", record)
}
if token == "" {
t.Fatal("preview token is empty")
}
completed, err := service.Execute(context.Background(), record.ID, token)
if err != nil {
t.Fatal(err)
}
if completed.Status != StatusSucceeded {
t.Fatalf("migration status = %s, want succeeded", completed.Status)
}
if _, err := service.Execute(context.Background(), record.ID, token); !errors.Is(err, ErrMigrationState) {
t.Fatalf("replayed token returned %v, want state error", err)
}
}
func TestPreviewRejectsUnsafeTargetName(t *testing.T) {
service := NewService(testRunner{})
if _, _, err := service.Preview(context.Background(), testDatabase(t), 1, "smail;drop"); !errors.Is(err, ErrInvalidTargetName) {
t.Fatalf("unsafe target returned %v", err)
}
}
func TestPreviewHonorsCanceledContextWithoutCreatingRecord(t *testing.T) {
service := NewService(testRunner{})
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, _, err := service.Preview(ctx, testDatabase(t), 1, "smail_next"); !errors.Is(err, context.Canceled) {
t.Fatalf("canceled preview returned %v, want context canceled", err)
}
service.mu.RLock()
defer service.mu.RUnlock()
if len(service.records) != 0 {
t.Fatalf("canceled preview created %d records", len(service.records))
}
}
func TestFailedExecutionKeepsSafeState(t *testing.T) {
service := NewService(nil)
record, token, err := service.Preview(context.Background(), testDatabase(t), 1, "smail_next")
if err != nil {
t.Fatal(err)
}
failed, err := service.Execute(context.Background(), record.ID, token)
if !errors.Is(err, ErrExecutorUnavailable) {
t.Fatalf("execution returned %v, want executor unavailable", err)
}
if failed.Status != StatusFailed || failed.ErrorCode != "EXECUTOR_UNAVAILABLE" {
t.Fatalf("unexpected failed state: %+v", failed)
}
}
func TestMigrationErrorCodeMapsExecutorFailures(t *testing.T) {
tests := []struct {
err error
code string
}{
{err: ErrSQLTargetExists, code: "TARGET_EXISTS"},
{err: ErrSQLCopyUnavailable, code: "COPIER_UNAVAILABLE"},
{err: ErrSQLConnectionUnavailable, code: "CONNECTION_UNAVAILABLE"},
{err: ErrSQLUnsupportedObject, code: "UNSUPPORTED_OBJECT"},
{err: ErrSQLDataVerification, code: "DATA_VERIFICATION_FAILED"},
{err: ErrSQLSourceBusy, code: "SOURCE_BUSY"},
{err: ErrSwitchUnavailable, code: "SWITCH_UNAVAILABLE"},
{err: ErrSwitchOutcomeUnknown, code: "SWITCH_OUTCOME_UNKNOWN"},
}
for _, test := range tests {
t.Run(test.code, func(t *testing.T) {
if got := migrationErrorCode(test.err); got != test.code {
t.Fatalf("migration error code = %q, want %q", got, test.code)
}
})
}
}
func TestSQLitePreviewChangesOnlyLocalReference(t *testing.T) {
service := NewService(testRunner{})
source := config.DatabaseConfig{Type: config.DatabaseTypeSQLite, LocalRef: "smail.db"}
record, _, err := service.Preview(context.Background(), source, 1, "smail-next.db")
if err != nil {
t.Fatal(err)
}
if record.Target.LocalRef != "smail-next.db" || record.Target.Type != config.DatabaseTypeSQLite || record.Target.Name != "" {
t.Fatalf("unexpected sqlite target: %+v", record.Target)
}
}
type invalidTransitionRunner struct{}
func (invalidTransitionRunner) Run(_ context.Context, _ Plan, update func(Status) error) error {
return update(StatusCreated)
}
func TestMigrationRejectsBackwardStatusTransition(t *testing.T) {
service := NewService(invalidTransitionRunner{})
record, token, err := service.Preview(context.Background(), testDatabase(t), 1, "smail_next")
if err != nil {
t.Fatal(err)
}
failed, err := service.Execute(context.Background(), record.ID, token)
if !errors.Is(err, ErrMigrationState) {
t.Fatalf("execution returned %v, want state error", err)
}
if failed.Status != StatusFailed || failed.ErrorCode != "MIGRATION_FAILED" {
t.Fatalf("unexpected failed migration: %+v", failed)
}
}
func TestMigrationRecordsRemainIndependentDuringConcurrentExecution(t *testing.T) {
service := NewService(testRunner{})
first, firstToken, err := service.Preview(context.Background(), testDatabase(t), 1, "smail_first")
if err != nil {
t.Fatal(err)
}
second, secondToken, err := service.Preview(context.Background(), testDatabase(t), 1, "smail_second")
if err != nil {
t.Fatal(err)
}
results := make(chan struct {
record Record
err error
}, 2)
go func() {
record, err := service.Execute(context.Background(), first.ID, firstToken)
results <- struct {
record Record
err error
}{record: record, err: err}
}()
go func() {
record, err := service.Execute(context.Background(), second.ID, secondToken)
results <- struct {
record Record
err error
}{record: record, err: err}
}()
for range 2 {
result := <-results
if result.err != nil {
t.Fatalf("concurrent execution failed: %v", result.err)
}
if result.record.Status != StatusSucceeded {
t.Fatalf("unexpected concurrent status: %+v", result.record)
}
if result.record.Target.Name != "smail_first" && result.record.Target.Name != "smail_second" {
t.Fatalf("unexpected concurrent target: %+v", result.record.Target)
}
}
firstState, err := service.Get(first.ID)
if err != nil {
t.Fatal(err)
}
secondState, err := service.Get(second.ID)
if err != nil {
t.Fatal(err)
}
if firstState.Target.Name != "smail_first" || secondState.Target.Name != "smail_second" {
t.Fatalf("migration records crossed: first=%+v second=%+v", firstState.Target, secondState.Target)
}
}
func TestExecuteAuthorizedPassesActorToSQLiteSwitch(t *testing.T) {
var received config.AuditActor
var receivedGeneration uint64
root := t.TempDir()
switchConfig := func(_ context.Context, generation uint64, _ config.DatabaseConfig, actor config.AuditActor) error {
receivedGeneration = generation
received = actor
return nil
}
executor := NewAuthorizedSQLiteExecutor(root, switchConfig)
service := NewService(executor)
actor := config.AuditActor{UserID: "admin-1", Role: "super_admin", SessionID: "session-1"}
if _, err := service.ExecuteAuthorized(context.Background(), "missing", "missing", actor); !errors.Is(err, ErrMigrationNotFound) {
t.Fatalf("missing migration error = %v, want migration not found", err)
}
if _, err := service.ExecuteAuthorized(context.Background(), "missing", "missing", config.AuditActor{UserID: "admin-1"}); !errors.Is(err, config.ErrAuthorizationRequired) {
t.Fatalf("missing session error = %v, want authorization required", err)
}
root = t.TempDir()
executor = NewAuthorizedSQLiteExecutor(root, switchConfig)
service = NewService(executor)
configDirectory := filepath.Join(root, "data")
if err := os.Mkdir(configDirectory, 0700); err != nil {
t.Fatal(err)
}
database, err := sql.Open("sqlite", filepath.Join(configDirectory, "smail.db"))
if err != nil {
t.Fatal(err)
}
if _, err := database.Exec("CREATE TABLE messages (id INTEGER PRIMARY KEY, body TEXT NOT NULL)"); err != nil {
database.Close()
t.Fatal(err)
}
if err := database.Close(); err != nil {
t.Fatal(err)
}
record, token, err := service.Preview(context.Background(), config.DatabaseConfig{Type: config.DatabaseTypeSQLite, LocalRef: "smail.db"}, 1, "next.db")
if err != nil {
t.Fatal(err)
}
completed, err := service.ExecuteAuthorized(context.Background(), record.ID, token, actor)
if err != nil {
t.Fatal(err)
}
if completed.Status != StatusSucceeded || received != actor || receivedGeneration != record.CurrentGeneration {
t.Fatalf("authorized execution = %+v, received actor = %+v, generation = %d", completed, received, receivedGeneration)
}
}