Files
netbird/management/internals/shared/grpc/sync_fast_path_response_test.go
mlsmaycon 3f4ef0031b [management] Skip full network map on Sync when peer state is unchanged
Introduce a peer-sync cache keyed by WireGuard pubkey that records the
NetworkMap.Serial and meta hash the server last delivered to each peer.
When a Sync request arrives from a non-Android peer whose cached serial
matches the current account serial and whose meta hash matches the last
delivery, short-circuit SyncAndMarkPeer and reply with a NetbirdConfig-only
SyncResponse mirroring the shape TimeBasedAuthSecretsManager already pushes
for TURN/Relay token rotation. The client keeps its existing network map
state and refreshes only control-plane credentials.

The fast path avoids GetAccountWithBackpressure, the full per-peer map
assembly, posture-check recomputation and the large encrypted payload on
every reconnect of a peer whose account is quiescent. Slow path remains
the source of truth for any real state change; every full-map send (initial
sync or streamed NetworkMap update) rewrites the cache, and every Login
deletes it so a fresh map is guaranteed after SSH key rotation, approval
changes or re-registration.

Backend-only: no proto changes and no client changes. Compatibility is
provided by the existing client handling of nil NetworkMap in handleSync
(every version from v0.20.0 on). Android is gated out at the server because
its readInitialSettings path calls GrpcClient.GetNetworkMap which errors on
nil map. The cache is wired through BaseServer.CacheStore() so it shares
the same Redis/in-memory backend as OneTimeTokenStore and PKCEVerifierStore.

Test coverage lands in four layers:
- Pure decision function (peer_serial_cache_decision_test.go)
- Cache wrapper with TTL + concurrency (peer_serial_cache_test.go)
- Response shape unit tests (sync_fast_path_response_test.go)
- In-process gRPC behavioural tests covering first sync, reconnect skip,
  android never-skip, meta change, login invalidation, and serial advance
  (management/server/sync_fast_path_test.go)
- Frozen SyncRequest wire-format fixtures for v0.20.0 / v0.40.0 / v0.60.0
  / current / android replayed against the in-process server
  (management/server/sync_legacy_wire_test.go + testdata fixtures)
2026-04-17 16:20:04 +02:00

164 lines
6.7 KiB
Go

package grpc
import (
"context"
"testing"
"time"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
"github.com/netbirdio/netbird/management/internals/server/config"
"github.com/netbirdio/netbird/management/server/groups"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/settings"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/util"
)
func fastPathTestPeer() *nbpeer.Peer {
return &nbpeer.Peer{
ID: "peer-id",
AccountID: "account-id",
Key: "pubkey",
}
}
func fastPathTestSecrets(t *testing.T, turn *config.TURNConfig, relay *config.Relay) *TimeBasedAuthSecretsManager {
t.Helper()
peersManager := update_channel.NewPeersUpdateManager(nil)
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
settingsMock := settings.NewMockManager(ctrl)
secrets, err := NewTimeBasedAuthSecretsManager(peersManager, turn, relay, settingsMock, groups.NewManagerMock())
require.NoError(t, err, "secrets manager initialisation must succeed")
return secrets
}
func noGroupsFetcher(context.Context, string, string) ([]string, error) {
return nil, nil
}
func TestBuildFastPathResponse_TimeBasedTURNAndRelay_FreshTokens(t *testing.T) {
ttl := util.Duration{Duration: time.Hour}
turnCfg := &config.TURNConfig{
CredentialsTTL: ttl,
Secret: "turn-secret",
Turns: []*config.Host{TurnTestHost},
TimeBasedCredentials: true,
}
relayCfg := &config.Relay{
Addresses: []string{"rel.example:443"},
CredentialsTTL: ttl,
Secret: "relay-secret",
}
cfg := &config.Config{
TURNConfig: turnCfg,
Relay: relayCfg,
Signal: &config.Host{URI: "signal.example:443", Proto: config.HTTPS},
Stuns: []*config.Host{{URI: "stun.example:3478", Proto: config.UDP}},
}
secrets := fastPathTestSecrets(t, turnCfg, relayCfg)
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
settingsMock := settings.NewMockManager(ctrl)
settingsMock.EXPECT().GetExtraSettings(gomock.Any(), "account-id").Return(&types.ExtraSettings{}, nil).AnyTimes()
resp := buildFastPathResponse(context.Background(), cfg, secrets, settingsMock, noGroupsFetcher, fastPathTestPeer())
require.NotNil(t, resp, "response must not be nil")
assert.Nil(t, resp.NetworkMap, "fast path must omit NetworkMap")
assert.Nil(t, resp.PeerConfig, "fast path must omit PeerConfig")
assert.Empty(t, resp.Checks, "fast path must omit posture checks")
assert.Empty(t, resp.RemotePeers, "fast path must omit remote peers")
require.NotNil(t, resp.NetbirdConfig, "NetbirdConfig must be present on fast path")
require.Len(t, resp.NetbirdConfig.Turns, 1, "time-based TURN credentials must be present")
assert.NotEmpty(t, resp.NetbirdConfig.Turns[0].User, "TURN user must be populated")
assert.NotEmpty(t, resp.NetbirdConfig.Turns[0].Password, "TURN password must be populated")
require.NotNil(t, resp.NetbirdConfig.Relay, "Relay config must be present when configured")
assert.NotEmpty(t, resp.NetbirdConfig.Relay.TokenPayload, "relay token payload must be populated")
assert.NotEmpty(t, resp.NetbirdConfig.Relay.TokenSignature, "relay token signature must be populated")
assert.Equal(t, []string{"rel.example:443"}, resp.NetbirdConfig.Relay.Urls, "relay URLs passthrough")
require.NotNil(t, resp.NetbirdConfig.Signal, "Signal config must be present when configured")
assert.Equal(t, "signal.example:443", resp.NetbirdConfig.Signal.Uri, "signal URI passthrough")
require.Len(t, resp.NetbirdConfig.Stuns, 1, "STUNs must be passed through")
assert.Equal(t, "stun.example:3478", resp.NetbirdConfig.Stuns[0].Uri, "STUN URI passthrough")
}
func TestBuildFastPathResponse_StaticTURNCredentials(t *testing.T) {
ttl := util.Duration{Duration: time.Hour}
staticHost := &config.Host{
URI: "turn:static.example:3478",
Proto: config.UDP,
Username: "preset-user",
Password: "preset-pass",
}
turnCfg := &config.TURNConfig{
CredentialsTTL: ttl,
Secret: "turn-secret",
Turns: []*config.Host{staticHost},
TimeBasedCredentials: false,
}
cfg := &config.Config{TURNConfig: turnCfg}
// Use a relay-free secrets manager; static TURN path does not consult it.
secrets := fastPathTestSecrets(t, turnCfg, nil)
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
settingsMock := settings.NewMockManager(ctrl)
settingsMock.EXPECT().GetExtraSettings(gomock.Any(), gomock.Any()).Return(&types.ExtraSettings{}, nil).AnyTimes()
resp := buildFastPathResponse(context.Background(), cfg, secrets, settingsMock, noGroupsFetcher, fastPathTestPeer())
require.NotNil(t, resp.NetbirdConfig)
require.Len(t, resp.NetbirdConfig.Turns, 1, "static TURN must appear in response")
assert.Equal(t, "preset-user", resp.NetbirdConfig.Turns[0].User, "static user passthrough")
assert.Equal(t, "preset-pass", resp.NetbirdConfig.Turns[0].Password, "static password passthrough")
assert.Nil(t, resp.NetbirdConfig.Relay, "no Relay when Relay config is nil")
}
func TestBuildFastPathResponse_NoRelayConfigured_NoRelaySection(t *testing.T) {
cfg := &config.Config{}
secrets := fastPathTestSecrets(t, nil, nil)
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
settingsMock := settings.NewMockManager(ctrl)
settingsMock.EXPECT().GetExtraSettings(gomock.Any(), gomock.Any()).Return(&types.ExtraSettings{}, nil).AnyTimes()
resp := buildFastPathResponse(context.Background(), cfg, secrets, settingsMock, noGroupsFetcher, fastPathTestPeer())
require.NotNil(t, resp.NetbirdConfig, "NetbirdConfig must be non-nil even without relay/turn")
assert.Nil(t, resp.NetbirdConfig.Relay, "Relay must be absent when not configured")
assert.Empty(t, resp.NetbirdConfig.Turns, "Turns must be empty when not configured")
}
func TestBuildFastPathResponse_ExtraSettingsErrorStillReturnsResponse(t *testing.T) {
cfg := &config.Config{}
secrets := fastPathTestSecrets(t, nil, nil)
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
settingsMock := settings.NewMockManager(ctrl)
settingsMock.EXPECT().GetExtraSettings(gomock.Any(), gomock.Any()).Return(nil, assertAnError).AnyTimes()
resp := buildFastPathResponse(context.Background(), cfg, secrets, settingsMock, noGroupsFetcher, fastPathTestPeer())
require.NotNil(t, resp, "extra settings failure must degrade gracefully into an empty fast-path response")
assert.Nil(t, resp.NetworkMap, "NetworkMap still omitted on degraded path")
}
// assertAnError is a sentinel used by fast-path tests that need to simulate a
// dependency failure without caring about the error value.
var assertAnError = errForTests("simulated")
type errForTests string
func (e errForTests) Error() string { return string(e) }