mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-16 19:49:56 +00:00
165 lines
6.7 KiB
Go
165 lines
6.7 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
nbdns "github.com/netbirdio/netbird/dns"
|
|
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
|
"github.com/netbirdio/netbird/management/server/affectedpeers"
|
|
resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
|
|
routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
|
|
networkTypes "github.com/netbirdio/netbird/management/server/networks/types"
|
|
"github.com/netbirdio/netbird/management/server/store"
|
|
"github.com/netbirdio/netbird/management/server/types"
|
|
"github.com/netbirdio/netbird/route"
|
|
)
|
|
|
|
// countingStore wraps a real store and counts the per-account collection loads
|
|
// the resolver performs, so a test can assert each is read at most once and that
|
|
// irrelevant collections are skipped entirely.
|
|
type countingStore struct {
|
|
store.Store
|
|
mu sync.Mutex
|
|
counts map[string]int
|
|
}
|
|
|
|
func newCountingStore(s store.Store) *countingStore {
|
|
return &countingStore{Store: s, counts: map[string]int{}}
|
|
}
|
|
|
|
func (c *countingStore) bump(name string) {
|
|
c.mu.Lock()
|
|
c.counts[name]++
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
func (c *countingStore) count(name string) int {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.counts[name]
|
|
}
|
|
|
|
func (c *countingStore) total() int {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
n := 0
|
|
for _, v := range c.counts {
|
|
n += v
|
|
}
|
|
return n
|
|
}
|
|
|
|
func (c *countingStore) GetAccountPolicies(ctx context.Context, ls store.LockingStrength, accountID string) ([]*types.Policy, error) {
|
|
c.bump("policies")
|
|
return c.Store.GetAccountPolicies(ctx, ls, accountID)
|
|
}
|
|
|
|
func (c *countingStore) GetAccountRoutes(ctx context.Context, ls store.LockingStrength, accountID string) ([]*route.Route, error) {
|
|
c.bump("routes")
|
|
return c.Store.GetAccountRoutes(ctx, ls, accountID)
|
|
}
|
|
|
|
func (c *countingStore) GetAccountNameServerGroups(ctx context.Context, ls store.LockingStrength, accountID string) ([]*nbdns.NameServerGroup, error) {
|
|
c.bump("nameservers")
|
|
return c.Store.GetAccountNameServerGroups(ctx, ls, accountID)
|
|
}
|
|
|
|
func (c *countingStore) GetAccountDNSSettings(ctx context.Context, ls store.LockingStrength, accountID string) (*types.DNSSettings, error) {
|
|
c.bump("dnssettings")
|
|
return c.Store.GetAccountDNSSettings(ctx, ls, accountID)
|
|
}
|
|
|
|
func (c *countingStore) GetNetworkRoutersByAccountID(ctx context.Context, ls store.LockingStrength, accountID string) ([]*routerTypes.NetworkRouter, error) {
|
|
c.bump("routers")
|
|
return c.Store.GetNetworkRoutersByAccountID(ctx, ls, accountID)
|
|
}
|
|
|
|
func (c *countingStore) GetNetworkResourcesByAccountID(ctx context.Context, ls store.LockingStrength, accountID string) ([]*resourceTypes.NetworkResource, error) {
|
|
c.bump("resources")
|
|
return c.Store.GetNetworkResourcesByAccountID(ctx, ls, accountID)
|
|
}
|
|
|
|
func (c *countingStore) GetAccountServices(ctx context.Context, ls store.LockingStrength, accountID string) ([]*rpservice.Service, error) {
|
|
c.bump("services")
|
|
return c.Store.GetAccountServices(ctx, ls, accountID)
|
|
}
|
|
|
|
// TestAffectedPeers_QueryCount_NoRedundantFullTableLoads asserts the resolver
|
|
// loads each per-account collection at most once per Resolve (memoization) even
|
|
// on a change that drives every bridge, and skips the services table when the
|
|
// account has no embedded proxy peers.
|
|
func TestAffectedPeers_QueryCount_NoRedundantFullTableLoads(t *testing.T) {
|
|
s := setupRouterScenario(t, true)
|
|
ctx := context.Background()
|
|
|
|
_, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
|
|
require.NoError(t, err)
|
|
|
|
cs := newCountingStore(s.manager.Store)
|
|
|
|
// A group change that exercises policies, routers, resources and the bridge.
|
|
change := affectedpeers.Change{ChangedGroupIDs: []string{s.sourceGroupID}}
|
|
snap, err := affectedpeers.Load(ctx, cs, s.accountID, change)
|
|
require.NoError(t, err)
|
|
affected := snap.Expand(ctx, s.accountID, change)
|
|
assert.Contains(t, affected, s.routerPeerID, "bridge must still resolve the routing peer")
|
|
|
|
for _, name := range []string{"policies", "routes", "nameservers", "dnssettings", "routers", "resources"} {
|
|
assert.LessOrEqualf(t, cs.count(name), 1,
|
|
"%s must be loaded at most once per Resolve, got %d", name, cs.count(name))
|
|
}
|
|
assert.Equal(t, 0, cs.count("services"),
|
|
"services must not be loaded when the account has no embedded proxy peers")
|
|
}
|
|
|
|
// TestAffectedPeers_QueryCount_NarrowChangeSkipsLoads asserts that a change with
|
|
// no group/peer signal touches no per-account collections beyond what its inputs
|
|
// require.
|
|
func TestAffectedPeers_QueryCount_NarrowChangeSkipsLoads(t *testing.T) {
|
|
s := setupRouterScenario(t, true)
|
|
ctx := context.Background()
|
|
|
|
cs := newCountingStore(s.manager.Store)
|
|
|
|
// A bare network change drives only the router->source bridge: routers and
|
|
// resources are needed, but routes/nameservers/dnssettings/services are not.
|
|
_, err := affectedpeers.Load(ctx, cs, s.accountID, affectedpeers.Change{Networks: []*networkTypes.Network{{ID: s.networkID}}})
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, 0, cs.count("routes"), "routes must not be loaded for a network-only change")
|
|
assert.Equal(t, 0, cs.count("nameservers"), "nameservers must not be loaded for a network-only change")
|
|
assert.Equal(t, 0, cs.count("dnssettings"), "dnssettings must not be loaded for a network-only change")
|
|
assert.Equal(t, 0, cs.count("services"), "services must not be loaded for a network-only change")
|
|
}
|
|
|
|
// TestAffectedPeers_QueryCount_ExpandReadsNothing is the core invariant of the
|
|
// Load/Expand split: Load (run inside the transaction) does all store reads;
|
|
// Expand (run after commit) must touch the store ZERO times, so it never holds
|
|
// the write lock and never reads post-commit state.
|
|
func TestAffectedPeers_QueryCount_ExpandReadsNothing(t *testing.T) {
|
|
s := setupRouterScenario(t, true)
|
|
ctx := context.Background()
|
|
|
|
_, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
|
|
require.NoError(t, err)
|
|
|
|
change := affectedpeers.Change{ChangedGroupIDs: []string{s.sourceGroupID}}
|
|
|
|
cs := newCountingStore(s.manager.Store)
|
|
snap, err := affectedpeers.Load(ctx, cs, s.accountID, change)
|
|
require.NoError(t, err)
|
|
require.Greater(t, cs.total(), 0, "Load must read the store")
|
|
|
|
// Any store access during Expand would increment the same counter. Expand
|
|
// operates purely on the snapshot, so the count must not move.
|
|
readsAfterLoad := cs.total()
|
|
affected := snap.Expand(ctx, s.accountID, change)
|
|
assert.Contains(t, affected, s.routerPeerID, "Expand must still produce the affected peers from the snapshot")
|
|
assert.Equal(t, readsAfterLoad, cs.total(), "Expand must perform zero store reads — it operates purely on the loaded snapshot")
|
|
}
|