package metrics import ( "context" "io" "net/http" "net/http/httptest" "net/url" "sync/atomic" "testing" "time" goversion "github.com/hashicorp/go-version" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/netbirdio/netbird/client/internal/metrics/remoteconfig" ) func mustVersion(s string) *goversion.Version { v, err := goversion.NewVersion(s) if err != nil { panic(err) } return v } func mustURL(s string) url.URL { u, err := url.Parse(s) if err != nil { panic(err) } return *u } func parseURL(s string) *url.URL { u, err := url.Parse(s) if err != nil { panic(err) } return u } func testConfig(serverURL, since, until string, period time.Duration) *remoteconfig.Config { return &remoteconfig.Config{ ServerURL: mustURL(serverURL), VersionSince: mustVersion(since), VersionUntil: mustVersion(until), Interval: period, } } // mockConfigProvider implements remoteConfigProvider for testing type mockConfigProvider struct { config *remoteconfig.Config } func (m *mockConfigProvider) RefreshIfNeeded(_ context.Context) *remoteconfig.Config { return m.config } // mockMetrics implements metricsImplementation for testing type mockMetrics struct { exportData string } func (m *mockMetrics) RecordConnectionStages(_ context.Context, _ AgentInfo, _ string, _ ConnectionType, _ bool, _ ConnectionStageTimestamps) { } func (m *mockMetrics) RecordSyncDuration(_ context.Context, _ AgentInfo, _ time.Duration) { } func (m *mockMetrics) RecordLoginDuration(_ context.Context, _ AgentInfo, _ time.Duration, _ bool) { } func (m *mockMetrics) Export(w io.Writer) error { if m.exportData != "" { _, err := w.Write([]byte(m.exportData)) return err } return nil } func (m *mockMetrics) Reset() { } func TestPush_OverrideIntervalPushes(t *testing.T) { var pushCount atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { pushCount.Add(1) w.WriteHeader(http.StatusNoContent) })) defer server.Close() metrics := &mockMetrics{exportData: "test_metric 1\n"} configProvider := &mockConfigProvider{config: testConfig(server.URL, "1.0.0", "2.0.0", 60*time.Minute)} push, err := NewPush(metrics, configProvider, PushConfig{ Interval: 50 * time.Millisecond, ServerAddress: parseURL(server.URL), }, "1.0.0") require.NoError(t, err) ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { push.Start(ctx) close(done) }() require.Eventually(t, func() bool { return pushCount.Load() >= 3 }, 2*time.Second, 10*time.Millisecond) cancel() <-done } func TestPush_RemoteConfigVersionInRange(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) })) defer server.Close() metrics := &mockMetrics{exportData: "test_metric 1\n"} configProvider := &mockConfigProvider{config: testConfig(server.URL, "1.0.0", "2.0.0", 1*time.Minute)} push, err := NewPush(metrics, configProvider, PushConfig{}, "1.5.0") require.NoError(t, err) pushURL, interval := push.resolve(context.Background()) assert.NotEmpty(t, pushURL) assert.Equal(t, 1*time.Minute, interval) } func TestPush_RemoteConfigVersionOutOfRange(t *testing.T) { metrics := &mockMetrics{exportData: "test_metric 1\n"} configProvider := &mockConfigProvider{config: testConfig("http://localhost", "1.0.0", "1.5.0", 1*time.Minute)} push, err := NewPush(metrics, configProvider, PushConfig{}, "2.0.0") require.NoError(t, err) pushURL, interval := push.resolve(context.Background()) assert.Empty(t, pushURL) assert.Equal(t, 1*time.Minute, interval) } func TestPush_NoConfigReturnsDefault(t *testing.T) { metrics := &mockMetrics{} configProvider := &mockConfigProvider{config: nil} push, err := NewPush(metrics, configProvider, PushConfig{}, "1.0.0") require.NoError(t, err) pushURL, interval := push.resolve(context.Background()) assert.Empty(t, pushURL) assert.Equal(t, defaultPushInterval, interval) } func TestPush_OverrideIntervalRespectsVersionCheck(t *testing.T) { metrics := &mockMetrics{} configProvider := &mockConfigProvider{config: testConfig("http://localhost", "3.0.0", "4.0.0", 60*time.Minute)} push, err := NewPush(metrics, configProvider, PushConfig{ Interval: 30 * time.Second, ServerAddress: parseURL("http://localhost"), }, "1.0.0") require.NoError(t, err) pushURL, interval := push.resolve(context.Background()) assert.Empty(t, pushURL) // version out of range assert.Equal(t, 30*time.Second, interval) // but uses override interval } func TestPush_OverrideIntervalUsedWhenVersionInRange(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) })) defer server.Close() metrics := &mockMetrics{} configProvider := &mockConfigProvider{config: testConfig(server.URL, "1.0.0", "2.0.0", 60*time.Minute)} push, err := NewPush(metrics, configProvider, PushConfig{ Interval: 30 * time.Second, }, "1.5.0") require.NoError(t, err) pushURL, interval := push.resolve(context.Background()) assert.NotEmpty(t, pushURL) assert.Equal(t, 30*time.Second, interval) } func TestPush_NoMetricsSkipsPush(t *testing.T) { var pushCount atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { pushCount.Add(1) w.WriteHeader(http.StatusNoContent) })) defer server.Close() metrics := &mockMetrics{exportData: ""} // no metrics to export configProvider := &mockConfigProvider{config: nil} push, err := NewPush(metrics, configProvider, PushConfig{}, "1.0.0") require.NoError(t, err) err = push.push(context.Background(), server.URL) assert.NoError(t, err) assert.Equal(t, int32(0), pushCount.Load()) } func TestPush_ServerURLFromRemoteConfig(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) })) defer server.Close() metrics := &mockMetrics{exportData: "test_metric 1\n"} configProvider := &mockConfigProvider{config: testConfig(server.URL, "1.0.0", "2.0.0", 1*time.Minute)} push, err := NewPush(metrics, configProvider, PushConfig{}, "1.5.0") require.NoError(t, err) pushURL, interval := push.resolve(context.Background()) assert.Contains(t, pushURL, server.URL) assert.Equal(t, 1*time.Minute, interval) } func TestPush_ServerAddressOverridesTakePrecedenceOverRemoteConfig(t *testing.T) { overrideServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) })) defer overrideServer.Close() metrics := &mockMetrics{exportData: "test_metric 1\n"} configProvider := &mockConfigProvider{config: testConfig("http://remote-config-server", "1.0.0", "2.0.0", 1*time.Minute)} push, err := NewPush(metrics, configProvider, PushConfig{ ServerAddress: parseURL(overrideServer.URL), }, "1.5.0") require.NoError(t, err) pushURL, _ := push.resolve(context.Background()) assert.Contains(t, pushURL, overrideServer.URL) assert.NotContains(t, pushURL, "remote-config-server") } func TestPush_OverrideIntervalWithoutOverrideURL_UsesRemoteConfigURL(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) })) defer server.Close() metrics := &mockMetrics{exportData: "test_metric 1\n"} configProvider := &mockConfigProvider{config: testConfig(server.URL, "1.0.0", "2.0.0", 60*time.Minute)} push, err := NewPush(metrics, configProvider, PushConfig{ Interval: 30 * time.Second, }, "1.0.0") require.NoError(t, err) pushURL, interval := push.resolve(context.Background()) assert.Contains(t, pushURL, server.URL) assert.Equal(t, 30*time.Second, interval) } func TestPush_NoConfigSkipsPush(t *testing.T) { metrics := &mockMetrics{exportData: "test_metric 1\n"} configProvider := &mockConfigProvider{config: nil} push, err := NewPush(metrics, configProvider, PushConfig{ Interval: 30 * time.Second, }, "1.0.0") require.NoError(t, err) pushURL, interval := push.resolve(context.Background()) assert.Empty(t, pushURL) assert.Equal(t, defaultPushInterval, interval) // no config available, use default retry interval } func TestPush_ForceSendingSkipsRemoteConfig(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) })) defer server.Close() metrics := &mockMetrics{exportData: "test_metric 1\n"} configProvider := &mockConfigProvider{config: nil} push, err := NewPush(metrics, configProvider, PushConfig{ ForceSending: true, Interval: 1 * time.Minute, ServerAddress: parseURL(server.URL), }, "1.0.0") require.NoError(t, err) pushURL, interval := push.resolve(context.Background()) assert.NotEmpty(t, pushURL) assert.Equal(t, 1*time.Minute, interval) } func TestPush_ForceSendingUsesDefaultInterval(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) })) defer server.Close() metrics := &mockMetrics{exportData: "test_metric 1\n"} configProvider := &mockConfigProvider{config: nil} push, err := NewPush(metrics, configProvider, PushConfig{ ForceSending: true, ServerAddress: parseURL(server.URL), }, "1.0.0") require.NoError(t, err) pushURL, interval := push.resolve(context.Background()) assert.NotEmpty(t, pushURL) assert.Equal(t, defaultPushInterval, interval) } func TestIsVersionInRange(t *testing.T) { tests := []struct { name string current string since string until string expected bool }{ {"at lower bound inclusive", "1.2.2", "1.2.2", "1.2.3", true}, {"in range", "1.2.2", "1.2.0", "1.3.0", true}, {"at upper bound exclusive", "1.2.3", "1.2.2", "1.2.3", false}, {"below range", "1.2.1", "1.2.2", "1.2.3", false}, {"above range", "1.3.0", "1.2.2", "1.2.3", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.expected, isVersionInRange(mustVersion(tt.current), mustVersion(tt.since), mustVersion(tt.until))) }) } }