diff --git a/backend/config/manager_v2.go b/backend/config/manager_v2.go index 4498f16..870ac04 100644 --- a/backend/config/manager_v2.go +++ b/backend/config/manager_v2.go @@ -528,7 +528,8 @@ func (m *ConfigManager) PreviewRollback(ctx context.Context, generation uint64, return nil, err } m.mu.RLock() - currentGeneration := m.current.Generation + current := cloneRuntimeConfig(m.current) + currentGeneration := current.Generation pending := m.pending != nil m.mu.RUnlock() if pending || generation >= currentGeneration { @@ -547,11 +548,22 @@ func (m *ConfigManager) PreviewRollback(ctx context.Context, generation uint64, if err := validateRollbackSSL(m.loader.root, backup); err != nil { return nil, err } + changed := make(map[Section]bool) + for _, change := range AuditChanges(current, backup) { + section, _, _ := strings.Cut(change.Field, ".") + changed[Section(section)] = true + } + changedSections := make([]Section, 0, len(changed)) + for _, section := range []Section{SectionRoot, SectionSMTP, SectionEmail, SectionSSL, SectionPlugins} { + if changed[section] { + changedSections = append(changedSections, section) + } + } return &RollbackPreview{ CurrentGeneration: currentGeneration, TargetGeneration: generation, CandidateGeneration: currentGeneration + 1, - ChangedSections: RequiredPartitionSections(), + ChangedSections: changedSections, }, nil } diff --git a/backend/config/manager_v2_test.go b/backend/config/manager_v2_test.go index b95239c..cf23f74 100644 --- a/backend/config/manager_v2_test.go +++ b/backend/config/manager_v2_test.go @@ -6,6 +6,7 @@ import ( "errors" "os" "path/filepath" + "slices" "strings" "sync" "testing" @@ -1720,6 +1721,71 @@ func TestConfigManagerRollbackTicketIsBoundAndSingleUse(t *testing.T) { } } +func TestConfigManagerRollbackPreviewChangedSections(t *testing.T) { + tests := []struct { + name string + sections []Section + mutate bool + want []Section + }{ + {name: "root_only", sections: []Section{SectionRoot}, mutate: true, want: []Section{SectionRoot}}, + {name: "email_only", sections: []Section{SectionEmail}, mutate: true, want: []Section{SectionEmail}}, + {name: "stable_order", sections: []Section{SectionEmail, SectionRoot}, mutate: true, want: []Section{SectionRoot, SectionEmail}}, + {name: "metadata_only", sections: []Section{SectionEmail}, want: []Section{}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := t.TempDir() + writeRuntimeFixture(t, root, validRuntimeConfig()) + options := ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}} + manager, err := NewConfigManager(root, options) + if err != nil { + t.Fatal(err) + } + for _, section := range test.sections { + current := manager.Snapshot() + var value any + switch section { + case SectionRoot: + if test.mutate { + current.Root.LogLevel = "warn" + } + value = current.Root + case SectionEmail: + if test.mutate { + current.Email.Policy.MaxMessageBytes = 8 << 20 + } + value = current.Email + } + payload, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + updated, err := manager.Update(context.Background(), UpdateRequest{ + Section: section, ExpectedGeneration: current.Generation, + Payload: payload, Actor: authorizedConfigActor(), + }) + if err != nil { + t.Fatal(err) + } + if _, err := manager.Apply(context.Background(), updated.Generation); err != nil { + t.Fatal(err) + } + } + preview, err := manager.PreviewRollback(context.Background(), 1, authorizedConfigActor()) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(preview.ChangedSections, test.want) { + t.Fatalf("changed sections = %v, want %v", preview.ChangedSections, test.want) + } + if preview.ChangedSections == nil { + t.Fatal("changed sections must be an array, not null") + } + }) + } +} + func TestConfigManagerRollbackRequiresStepUpButNotRecoveryCodeForSuperAdmin(t *testing.T) { root := t.TempDir() current := validRuntimeConfig()