From 877e8892502c66dc738f20090f50cd8e4ba9c68e Mon Sep 17 00:00:00 2001 From: dmitri-netbird Date: Fri, 17 Jul 2026 10:38:43 +0200 Subject: [PATCH] [management] fix fetching of missing settings in GetAccount call (#6800) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes ## Issue ticket number and link ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [x] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with Codesmith Autofix with Codesmith Need help on this PR? Tag /codesmith with what you need. Autofix is disabled. ## Summary by CodeRabbit * **Bug Fixes** * Ensure account settings are fully preserved through save/load, including automatic update and peer exposure preferences. * **Tests** * Added coverage to verify account settings remain unchanged after database persistence and retrieval (skipped on Windows due to SQLite limitations). * Introduced deterministic test-data population helpers to reliably set struct fields for deeper settings verification. --------- Signed-off-by: Dmitri Dolguikh --- management/server/store/sql_store.go | 22 ++++- management/server/store/sql_store_test.go | 49 +++++++++++ shared/testing_helpers/populate_fields.go | 101 ++++++++++++++++++++++ 3 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 shared/testing_helpers/populate_fields.go diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index f3e24298d..bb1650d54 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -1606,7 +1606,8 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc settings_routing_peer_dns_resolution_enabled, settings_dns_domain, settings_network_range, settings_network_range_v6, settings_ipv6_enabled_groups, settings_lazy_connection_enabled, settings_local_mfa_enabled, settings_metrics_push_enabled, settings_agent_network_only, - settings_dashboard_features, + settings_dashboard_features, settings_auto_update_version, settings_auto_update_always, + settings_peer_expose_enabled, settings_peer_expose_groups, -- Embedded ExtraSettings settings_extra_peer_approval_enabled, settings_extra_user_approval_required, settings_extra_integrated_validator, settings_extra_integrated_validator_groups @@ -1632,6 +1633,10 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc sMetricsPushEnabled sql.NullBool sAgentNetworkOnly sql.NullBool sDashboardFeatures sql.NullString + autoUpdateVersion sql.NullString + autoUpdateAlways sql.NullBool + peerExposeEnabled sql.NullBool + peerExposeGroups sql.NullString sExtraPeerApprovalEnabled sql.NullBool sExtraUserApprovalRequired sql.NullBool sExtraIntegratedValidator sql.NullString @@ -1655,7 +1660,8 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc &sRoutingPeerDNSResolutionEnabled, &sDNSDomain, &sNetworkRange, &sNetworkRangeV6, &sIPv6EnabledGroups, &sLazyConnectionEnabled, &sLocalMFAEnabled, &sMetricsPushEnabled, &sAgentNetworkOnly, - &sDashboardFeatures, + &sDashboardFeatures, &autoUpdateVersion, &autoUpdateAlways, + &peerExposeEnabled, &peerExposeGroups, &sExtraPeerApprovalEnabled, &sExtraUserApprovalRequired, &sExtraIntegratedValidator, &sExtraIntegratedValidatorGroups, ) @@ -1747,6 +1753,18 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc if sIPv6EnabledGroups.Valid { _ = json.Unmarshal([]byte(sIPv6EnabledGroups.String), &account.Settings.IPv6EnabledGroups) } + if autoUpdateAlways.Valid { + account.Settings.AutoUpdateAlways = autoUpdateAlways.Bool + } + if autoUpdateVersion.Valid { + account.Settings.AutoUpdateVersion = autoUpdateVersion.String + } + if peerExposeEnabled.Valid { + account.Settings.PeerExposeEnabled = peerExposeEnabled.Bool + } + if peerExposeGroups.Valid { + _ = json.Unmarshal([]byte(peerExposeGroups.String), &account.Settings.PeerExposeGroups) + } if sExtraPeerApprovalEnabled.Valid { account.Settings.Extra.PeerApprovalEnabled = sExtraPeerApprovalEnabled.Bool diff --git a/management/server/store/sql_store_test.go b/management/server/store/sql_store_test.go index 58f62be32..258e1aaa0 100644 --- a/management/server/store/sql_store_test.go +++ b/management/server/store/sql_store_test.go @@ -9,6 +9,7 @@ import ( "net" "net/netip" "os" + "reflect" "runtime" "sort" "sync" @@ -34,6 +35,7 @@ import ( "github.com/netbirdio/netbird/management/server/util" nbroute "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/status" + "github.com/netbirdio/netbird/shared/testing_helpers" "github.com/netbirdio/netbird/util/crypt" ) @@ -296,6 +298,53 @@ func Test_SaveAccount(t *testing.T) { }) } +func Test_AccountSettings_SaveAndRetrieve(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("The SQLite store is not properly supported by Windows yet") + } + + populateFields := testing_helpers.NewPopulateFields().WithCustomFieldSetter( + reflect.PointerTo(reflect.TypeOf(types.ExtraSettings{})), func(this *testing_helpers.PopulateFields, field reflect.Value) (int, error) { + es := types.ExtraSettings{} + reflectedEs := reflect.ValueOf(&es).Elem() + n, err := this.PopulateAll(reflectedEs) + if err != nil { + return n, err + } + field.Set(reflectedEs.Addr()) + return n, nil + }).WithCustomFieldSetter( + reflect.PointerTo(reflect.TypeOf(types.DashboardFeatures{})), func(this *testing_helpers.PopulateFields, field reflect.Value) (int, error) { + t := true + df := types.DashboardFeatures{AgentNetwork: &t} + reflectedDf := reflect.ValueOf(&df).Elem() + field.Set(reflectedDf.Addr()) + return 1, nil + }).WithSkippedTag("gorm", "-") + + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + account := newAccountWithId(context.Background(), "account_id", "testuser", "") + setupKey, _ := types.GenerateDefaultSetupKey() + account.SetupKeys[setupKey.Key] = setupKey + + settings := types.Settings{} + numOfExportedFields, err := populateFields.PopulateAll(reflect.ValueOf(&settings).Elem()) + assert.NoError(t, err) + assert.Equal(t, 27, numOfExportedFields) + account.Settings = &settings + + err = store.SaveAccount(context.Background(), account) + assert.NoError(t, err) + + accountFromDb, err := store.GetAccount(context.Background(), account.Id) + assert.NoError(t, err) + assert.NotNil(t, accountFromDb) + assert.NotNil(t, accountFromDb.Settings) + + assert.True(t, reflect.DeepEqual(&settings, accountFromDb.Settings), "created settings and settings retrieved from the db should match") + }) +} + func TestSqlite_DeleteAccount(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("The SQLite store is not properly supported by Windows yet") diff --git a/shared/testing_helpers/populate_fields.go b/shared/testing_helpers/populate_fields.go new file mode 100644 index 000000000..c93d62b69 --- /dev/null +++ b/shared/testing_helpers/populate_fields.go @@ -0,0 +1,101 @@ +package testing_helpers + +import ( + "fmt" + "net/netip" + "reflect" +) + +type PopulateFields struct { + CustomFieldSetters map[reflect.Type]func(this *PopulateFields, field reflect.Value) (int, error) + TagsToSkip map[string]string +} + +func NewPopulateFields() *PopulateFields { + return &PopulateFields{CustomFieldSetters: defaultCustomFieldSetters(), TagsToSkip: make(map[string]string)} +} + +func (p *PopulateFields) WithCustomFieldSetter(t reflect.Type, f func(this *PopulateFields, field reflect.Value) (int, error)) *PopulateFields { + p.CustomFieldSetters[t] = f + return p +} + +func (p *PopulateFields) WithSkippedTag(tag, value string) *PopulateFields { + p.TagsToSkip[tag] = value + return p +} + +func (p *PopulateFields) PopulateAll(v reflect.Value) (int, error) { + typ := v.Type() + totalExportedFields := 0 + for i := 0; i < typ.NumField(); i++ { + f := typ.Field(i) + if f.PkgPath != "" { // unexported + continue + } + + if p.skippedTagPresent(f.Tag) { + continue + } + + numOfExportedFields, err := p.setNonZero(v.Field(i)) + totalExportedFields += numOfExportedFields + if err != nil { + return totalExportedFields, err + } + } + return totalExportedFields, nil +} + +// setNonZero assigns a deterministic non-zero value to a field based on its kind, +// recursing into nested structs and populating one element of slice fields. +func (p *PopulateFields) setNonZero(field reflect.Value) (int, error) { + if f, ok := p.CustomFieldSetters[field.Type()]; ok { + return f(p, field) + } + + switch field.Kind() { + case reflect.String: + field.SetString("non-zero") + case reflect.Bool: + field.SetBool(true) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + field.SetInt(7) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + field.SetUint(7) + case reflect.Float32, reflect.Float64: + field.SetFloat(7) + case reflect.Struct: + n, err := p.PopulateAll(field) + return n + 1, err + case reflect.Slice: + s := reflect.MakeSlice(field.Type(), 1, 1) + _, err := p.setNonZero(s.Index(0)) + if err != nil { + return 0, err + } + field.Set(s) + default: + return 0, fmt.Errorf("unhandled field kind %s; extend setNonZero", field.Kind()) + } + + return 1, nil +} + +func defaultCustomFieldSetters() map[reflect.Type]func(this *PopulateFields, field reflect.Value) (int, error) { + return map[reflect.Type]func(this *PopulateFields, field reflect.Value) (int, error){ + reflect.TypeOf(netip.Prefix{}): func(_ *PopulateFields, field reflect.Value) (int, error) { + field.Set(reflect.ValueOf(netip.MustParsePrefix("10.0.0.0/24"))) + return 1, nil + }, + } +} + +func (p *PopulateFields) skippedTagPresent(t reflect.StructTag) bool { + for tag, value := range p.TagsToSkip { + if v := t.Get(tag); v == value { + return true + } + } + return false +}