diff --git a/server/config/manager_v2.go b/server/config/manager_v2.go index 86516a7..411c609 100644 --- a/server/config/manager_v2.go +++ b/server/config/manager_v2.go @@ -173,6 +173,9 @@ func newConfigManager(root string, options ConfigLoaderOptions, security ConfigS if err != nil { return nil, err } + if err := ValidateRuntimeSecrets(vault, runtime); err != nil { + return nil, err + } snapshots, err := NewSnapshotStore(runtime, options.Validation) if err != nil { return nil, err @@ -204,6 +207,9 @@ func (m *ConfigManager) Load(ctx context.Context) error { if err != nil { return err } + if err := ValidateRuntimeSecrets(m.vault, runtime); err != nil { + return err + } if err := m.snapshots.Publish(runtime); err != nil { return err } diff --git a/server/config/manager_v2_test.go b/server/config/manager_v2_test.go index 7a75d77..4912314 100644 --- a/server/config/manager_v2_test.go +++ b/server/config/manager_v2_test.go @@ -139,6 +139,55 @@ func TestConfigLoaderRejectsMissingPartitionAndGenerationMismatch(t *testing.T) } } +func TestConfigManagerRejectsUnavailableRuntimeSecretsAtStartup(t *testing.T) { + root := t.TempDir() + runtime := validMySQLRuntimeConfig(t, root) + otherVault, err := credential.NewVault(t.TempDir()) + if err != nil { + t.Fatal(err) + } + foreignCiphertext, err := otherVault.Encrypt("foreign-database-secret") + if err != nil { + t.Fatal(err) + } + runtime.Root.Database.Password.Ciphertext = foreignCiphertext + writeRuntimeFixture(t, root, runtime) + + _, err = NewConfigManager(root, ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}}) + if !errors.Is(err, ErrSecretUnavailable) { + t.Fatalf("manager construction error = %v, want unavailable secret", err) + } +} + +func TestConfigManagerLoadRejectsUnavailableRuntimeSecretsWithoutChangingSnapshot(t *testing.T) { + root := t.TempDir() + options := ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}} + writeRuntimeFixture(t, root, validMySQLRuntimeConfig(t, root)) + manager, err := NewConfigManager(root, options) + if err != nil { + t.Fatal(err) + } + current := manager.Snapshot() + otherVault, err := credential.NewVault(t.TempDir()) + if err != nil { + t.Fatal(err) + } + foreignCiphertext, err := otherVault.Encrypt("foreign-database-secret") + if err != nil { + t.Fatal(err) + } + current.Root.Database.Password.Ciphertext = foreignCiphertext + writeRuntimeFixture(t, root, current) + + if err := manager.Load(context.Background()); !errors.Is(err, ErrSecretUnavailable) { + t.Fatalf("load error = %v, want unavailable secret", err) + } + loaded := manager.Snapshot() + if loaded.Root.Database.Password.Ciphertext == foreignCiphertext { + t.Fatal("rejected load changed the active snapshot") + } +} + func TestConfigManagerApplyPreflightsSnapshotBeforePersisting(t *testing.T) { root := t.TempDir() writeRuntimeFixture(t, root, validRuntimeConfig()) diff --git a/server/services/dbmigration/service.go b/server/services/dbmigration/service.go index 3cee048..9db6377 100644 --- a/server/services/dbmigration/service.go +++ b/server/services/dbmigration/service.go @@ -116,7 +116,10 @@ func Current() *Service { return current.value } -func (s *Service) Preview(_ context.Context, source config.DatabaseConfig, currentGeneration uint64, targetName string) (Record, string, error) { +func (s *Service) Preview(ctx context.Context, source config.DatabaseConfig, currentGeneration uint64, targetName string) (Record, string, error) { + if err := contextError(ctx); err != nil { + return Record{}, "", err + } if s == nil || currentGeneration == 0 { return Record{}, "", ErrInvalidRequest } @@ -139,6 +142,9 @@ func (s *Service) Preview(_ context.Context, source config.DatabaseConfig, curre if err != nil { return Record{}, "", ErrInvalidRequest } + if err := contextError(ctx); err != nil { + return Record{}, "", err + } now := s.currentTime() item := &record{Record: Record{ID: id, Status: StatusPreviewed, Source: plan.Source, Target: plan.Target, CurrentGeneration: currentGeneration, CreatedAt: now, UpdatedAt: now}, tokenHash: sha256.Sum256([]byte(token)), expiresAt: now.Add(previewLifetime), sourceConfig: plan.sourceConfig, targetConfig: plan.targetConfig} s.mu.Lock() diff --git a/server/services/dbmigration/service_test.go b/server/services/dbmigration/service_test.go index cde8cfb..bbb57a7 100644 --- a/server/services/dbmigration/service_test.go +++ b/server/services/dbmigration/service_test.go @@ -81,6 +81,21 @@ func TestPreviewRejectsUnsafeTargetName(t *testing.T) { } } +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")