//go:build !ios && !android package cmd import ( "encoding/json" "go/ast" "go/parser" "go/token" "os" "path/filepath" "strings" "testing" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/netbirdio/netbird/client/configs" ) func TestServiceParamsPath(t *testing.T) { original := configs.StateDir t.Cleanup(func() { configs.StateDir = original }) configs.StateDir = "/var/lib/netbird" assert.Equal(t, filepath.Join("/var/lib/netbird", "service.json"), serviceParamsPath()) configs.StateDir = "/custom/state" assert.Equal(t, filepath.Join("/custom/state", "service.json"), serviceParamsPath()) } func TestSaveAndLoadServiceParams(t *testing.T) { tmpDir := t.TempDir() original := configs.StateDir t.Cleanup(func() { configs.StateDir = original }) configs.StateDir = tmpDir params := &serviceParams{ LogLevel: "debug", DaemonAddr: "unix:///var/run/netbird.sock", ManagementURL: "https://my.server.com", ConfigPath: "/etc/netbird/config.json", LogFiles: []string{"/var/log/netbird/client.log", "console"}, DisableProfiles: true, DisableUpdateSettings: false, ServiceEnvVars: map[string]string{"NB_LOG_FORMAT": "json", "CUSTOM": "val"}, } err := saveServiceParams(params) require.NoError(t, err) // Verify the file exists and is valid JSON. data, err := os.ReadFile(filepath.Join(tmpDir, "service.json")) require.NoError(t, err) assert.True(t, json.Valid(data)) loaded, err := loadServiceParams() require.NoError(t, err) require.NotNil(t, loaded) assert.Equal(t, params.LogLevel, loaded.LogLevel) assert.Equal(t, params.DaemonAddr, loaded.DaemonAddr) assert.Equal(t, params.ManagementURL, loaded.ManagementURL) assert.Equal(t, params.ConfigPath, loaded.ConfigPath) assert.Equal(t, params.LogFiles, loaded.LogFiles) assert.Equal(t, params.DisableProfiles, loaded.DisableProfiles) assert.Equal(t, params.DisableUpdateSettings, loaded.DisableUpdateSettings) assert.Equal(t, params.ServiceEnvVars, loaded.ServiceEnvVars) } func TestLoadServiceParams_FileNotExists(t *testing.T) { tmpDir := t.TempDir() original := configs.StateDir t.Cleanup(func() { configs.StateDir = original }) configs.StateDir = tmpDir params, err := loadServiceParams() assert.NoError(t, err) assert.Nil(t, params) } func TestLoadServiceParams_InvalidJSON(t *testing.T) { tmpDir := t.TempDir() original := configs.StateDir t.Cleanup(func() { configs.StateDir = original }) configs.StateDir = tmpDir err := os.WriteFile(filepath.Join(tmpDir, "service.json"), []byte("not json"), 0600) require.NoError(t, err) params, err := loadServiceParams() assert.Error(t, err) assert.Nil(t, params) } func TestCurrentServiceParams(t *testing.T) { origLogLevel := logLevel origDaemonAddr := daemonAddr origManagementURL := managementURL origConfigPath := configPath origLogFiles := logFiles origProfilesDisabled := profilesDisabled origUpdateSettingsDisabled := updateSettingsDisabled origServiceEnvVars := serviceEnvVars t.Cleanup(func() { logLevel = origLogLevel daemonAddr = origDaemonAddr managementURL = origManagementURL configPath = origConfigPath logFiles = origLogFiles profilesDisabled = origProfilesDisabled updateSettingsDisabled = origUpdateSettingsDisabled serviceEnvVars = origServiceEnvVars }) logLevel = "trace" daemonAddr = "tcp://127.0.0.1:9999" managementURL = "https://mgmt.example.com" configPath = "/tmp/test-config.json" logFiles = []string{"/tmp/test.log"} profilesDisabled = true updateSettingsDisabled = true serviceEnvVars = []string{"FOO=bar", "BAZ=qux"} params := currentServiceParams() assert.Equal(t, "trace", params.LogLevel) assert.Equal(t, "tcp://127.0.0.1:9999", params.DaemonAddr) assert.Equal(t, "https://mgmt.example.com", params.ManagementURL) assert.Equal(t, "/tmp/test-config.json", params.ConfigPath) assert.Equal(t, []string{"/tmp/test.log"}, params.LogFiles) assert.True(t, params.DisableProfiles) assert.True(t, params.DisableUpdateSettings) assert.Equal(t, map[string]string{"FOO": "bar", "BAZ": "qux"}, params.ServiceEnvVars) } func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) { origLogLevel := logLevel origDaemonAddr := daemonAddr origManagementURL := managementURL origConfigPath := configPath origLogFiles := logFiles origProfilesDisabled := profilesDisabled origUpdateSettingsDisabled := updateSettingsDisabled origServiceEnvVars := serviceEnvVars t.Cleanup(func() { logLevel = origLogLevel daemonAddr = origDaemonAddr managementURL = origManagementURL configPath = origConfigPath logFiles = origLogFiles profilesDisabled = origProfilesDisabled updateSettingsDisabled = origUpdateSettingsDisabled serviceEnvVars = origServiceEnvVars }) // Reset all flags to defaults. logLevel = "info" daemonAddr = "unix:///var/run/netbird.sock" managementURL = "" configPath = "/etc/netbird/config.json" logFiles = []string{"/var/log/netbird/client.log"} profilesDisabled = false updateSettingsDisabled = false serviceEnvVars = nil // Reset Changed state on all relevant flags. rootCmd.PersistentFlags().VisitAll(func(f *pflag.Flag) { f.Changed = false }) serviceCmd.PersistentFlags().VisitAll(func(f *pflag.Flag) { f.Changed = false }) // Simulate user explicitly setting --log-level via CLI. logLevel = "warn" require.NoError(t, rootCmd.PersistentFlags().Set("log-level", "warn")) saved := &serviceParams{ LogLevel: "debug", DaemonAddr: "tcp://127.0.0.1:5555", ManagementURL: "https://saved.example.com", ConfigPath: "/saved/config.json", LogFiles: []string{"/saved/client.log"}, DisableProfiles: true, DisableUpdateSettings: true, ServiceEnvVars: map[string]string{"SAVED_KEY": "saved_val"}, } cmd := &cobra.Command{} cmd.Flags().StringSlice("service-env", nil, "") applyServiceParams(cmd, saved) // log-level was Changed, so it should keep "warn", not use saved "debug". assert.Equal(t, "warn", logLevel) // All other fields were not Changed, so they should use saved values. assert.Equal(t, "tcp://127.0.0.1:5555", daemonAddr) assert.Equal(t, "https://saved.example.com", managementURL) assert.Equal(t, "/saved/config.json", configPath) assert.Equal(t, []string{"/saved/client.log"}, logFiles) assert.True(t, profilesDisabled) assert.True(t, updateSettingsDisabled) assert.Equal(t, []string{"SAVED_KEY=saved_val"}, serviceEnvVars) } func TestApplyServiceParams_BooleanRevertToFalse(t *testing.T) { origProfilesDisabled := profilesDisabled origUpdateSettingsDisabled := updateSettingsDisabled t.Cleanup(func() { profilesDisabled = origProfilesDisabled updateSettingsDisabled = origUpdateSettingsDisabled }) // Simulate current state where booleans are true (e.g. set by previous install). profilesDisabled = true updateSettingsDisabled = true // Reset Changed state so flags appear unset. serviceCmd.PersistentFlags().VisitAll(func(f *pflag.Flag) { f.Changed = false }) // Saved params have both as false. saved := &serviceParams{ DisableProfiles: false, DisableUpdateSettings: false, } cmd := &cobra.Command{} cmd.Flags().StringSlice("service-env", nil, "") applyServiceParams(cmd, saved) assert.False(t, profilesDisabled, "saved false should override current true") assert.False(t, updateSettingsDisabled, "saved false should override current true") } func TestApplyServiceParams_ClearManagementURL(t *testing.T) { origManagementURL := managementURL t.Cleanup(func() { managementURL = origManagementURL }) managementURL = "https://leftover.example.com" // Simulate saved params where management URL was explicitly cleared. saved := &serviceParams{ LogLevel: "info", DaemonAddr: "unix:///var/run/netbird.sock", // ManagementURL intentionally empty: was cleared with --management-url "". } rootCmd.PersistentFlags().VisitAll(func(f *pflag.Flag) { f.Changed = false }) cmd := &cobra.Command{} cmd.Flags().StringSlice("service-env", nil, "") applyServiceParams(cmd, saved) assert.Equal(t, "", managementURL, "saved empty management URL should clear the current value") } func TestApplyServiceParams_NilParams(t *testing.T) { origLogLevel := logLevel t.Cleanup(func() { logLevel = origLogLevel }) logLevel = "info" cmd := &cobra.Command{} cmd.Flags().StringSlice("service-env", nil, "") // Should be a no-op. applyServiceParams(cmd, nil) assert.Equal(t, "info", logLevel) } func TestApplyServiceEnvParams_MergeExplicitAndSaved(t *testing.T) { origServiceEnvVars := serviceEnvVars t.Cleanup(func() { serviceEnvVars = origServiceEnvVars }) // Set up a command with --service-env marked as Changed. cmd := &cobra.Command{} cmd.Flags().StringSlice("service-env", nil, "") require.NoError(t, cmd.Flags().Set("service-env", "EXPLICIT=yes,OVERLAP=explicit")) serviceEnvVars = []string{"EXPLICIT=yes", "OVERLAP=explicit"} saved := &serviceParams{ ServiceEnvVars: map[string]string{ "SAVED": "val", "OVERLAP": "saved", }, } applyServiceEnvParams(cmd, saved) // Parse result for easier assertion. result, err := parseServiceEnvVars(serviceEnvVars) require.NoError(t, err) assert.Equal(t, "yes", result["EXPLICIT"]) assert.Equal(t, "val", result["SAVED"]) // Explicit wins on conflict. assert.Equal(t, "explicit", result["OVERLAP"]) } func TestApplyServiceEnvParams_NotChanged(t *testing.T) { origServiceEnvVars := serviceEnvVars t.Cleanup(func() { serviceEnvVars = origServiceEnvVars }) serviceEnvVars = nil cmd := &cobra.Command{} cmd.Flags().StringSlice("service-env", nil, "") saved := &serviceParams{ ServiceEnvVars: map[string]string{"FROM_SAVED": "val"}, } applyServiceEnvParams(cmd, saved) result, err := parseServiceEnvVars(serviceEnvVars) require.NoError(t, err) assert.Equal(t, map[string]string{"FROM_SAVED": "val"}, result) } func TestApplyServiceEnvParams_ExplicitEmptyClears(t *testing.T) { origServiceEnvVars := serviceEnvVars t.Cleanup(func() { serviceEnvVars = origServiceEnvVars }) // Simulate --service-env "" which produces [""] in the slice. serviceEnvVars = []string{""} cmd := &cobra.Command{} cmd.Flags().StringSlice("service-env", nil, "") require.NoError(t, cmd.Flags().Set("service-env", "")) saved := &serviceParams{ ServiceEnvVars: map[string]string{"OLD_VAR": "should_be_cleared"}, } applyServiceEnvParams(cmd, saved) assert.Nil(t, serviceEnvVars, "explicit empty --service-env should clear all saved env vars") } func TestCurrentServiceParams_EmptyEnvVarsAfterParse(t *testing.T) { origServiceEnvVars := serviceEnvVars t.Cleanup(func() { serviceEnvVars = origServiceEnvVars }) // Simulate --service-env "" which produces [""] in the slice. serviceEnvVars = []string{""} params := currentServiceParams() // After parsing, the empty string is skipped, resulting in an empty map. // The map should still be set (not nil) so it overwrites saved values. assert.NotNil(t, params.ServiceEnvVars, "empty env vars should produce empty map, not nil") assert.Empty(t, params.ServiceEnvVars, "no valid env vars should be parsed from empty string") } // TestServiceParams_FieldsCoveredInFunctions ensures that all serviceParams fields are // referenced in both currentServiceParams() and applyServiceParams(). If a new field is // added to serviceParams but not wired into these functions, this test fails. func TestServiceParams_FieldsCoveredInFunctions(t *testing.T) { fset := token.NewFileSet() file, err := parser.ParseFile(fset, "service_params.go", nil, 0) require.NoError(t, err) // Collect all JSON field names from the serviceParams struct. structFields := extractStructJSONFields(t, file, "serviceParams") require.NotEmpty(t, structFields, "failed to find serviceParams struct fields") // Collect field names referenced in currentServiceParams and applyServiceParams. currentFields := extractFuncFieldRefs(t, file, "currentServiceParams", structFields) applyFields := extractFuncFieldRefs(t, file, "applyServiceParams", structFields) // applyServiceEnvParams handles ServiceEnvVars indirectly. applyEnvFields := extractFuncFieldRefs(t, file, "applyServiceEnvParams", structFields) for k, v := range applyEnvFields { applyFields[k] = v } for _, field := range structFields { assert.Contains(t, currentFields, field, "serviceParams field %q is not captured in currentServiceParams()", field) assert.Contains(t, applyFields, field, "serviceParams field %q is not restored in applyServiceParams()/applyServiceEnvParams()", field) } } // TestServiceParams_BuildArgsCoversAllFlags ensures that buildServiceArguments references // all serviceParams fields that should become CLI args. ServiceEnvVars is excluded because // it flows through newSVCConfig() EnvVars, not CLI args. func TestServiceParams_BuildArgsCoversAllFlags(t *testing.T) { fset := token.NewFileSet() file, err := parser.ParseFile(fset, "service_params.go", nil, 0) require.NoError(t, err) structFields := extractStructJSONFields(t, file, "serviceParams") require.NotEmpty(t, structFields) installerFile, err := parser.ParseFile(fset, "service_installer.go", nil, 0) require.NoError(t, err) // Fields that are handled outside of buildServiceArguments (env vars go through newSVCConfig). fieldsNotInArgs := map[string]bool{ "ServiceEnvVars": true, } buildFields := extractFuncGlobalRefs(t, installerFile, "buildServiceArguments") // Forward: every struct field must appear in buildServiceArguments. for _, field := range structFields { if fieldsNotInArgs[field] { continue } globalVar := fieldToGlobalVar(field) assert.Contains(t, buildFields, globalVar, "serviceParams field %q (global %q) is not referenced in buildServiceArguments()", field, globalVar) } // Reverse: every service-related global used in buildServiceArguments must // have a corresponding serviceParams field. This catches a developer adding // a new flag to buildServiceArguments without adding it to the struct. globalToField := make(map[string]string, len(structFields)) for _, field := range structFields { globalToField[fieldToGlobalVar(field)] = field } // Identifiers in buildServiceArguments that are not service params // (builtins, boilerplate, loop variables). nonParamGlobals := map[string]bool{ "args": true, "append": true, "string": true, "_": true, "logFile": true, // range variable over logFiles } for ref := range buildFields { if nonParamGlobals[ref] { continue } _, inStruct := globalToField[ref] assert.True(t, inStruct, "buildServiceArguments() references global %q which has no corresponding serviceParams field", ref) } } // extractStructJSONFields returns field names from a named struct type. func extractStructJSONFields(t *testing.T, file *ast.File, structName string) []string { t.Helper() var fields []string ast.Inspect(file, func(n ast.Node) bool { ts, ok := n.(*ast.TypeSpec) if !ok || ts.Name.Name != structName { return true } st, ok := ts.Type.(*ast.StructType) if !ok { return false } for _, f := range st.Fields.List { if len(f.Names) > 0 { fields = append(fields, f.Names[0].Name) } } return false }) return fields } // extractFuncFieldRefs returns which of the given field names appear inside the // named function, either as selector expressions (params.FieldName) or as // composite literal keys (&serviceParams{FieldName: ...}). func extractFuncFieldRefs(t *testing.T, file *ast.File, funcName string, fields []string) map[string]bool { t.Helper() fieldSet := make(map[string]bool, len(fields)) for _, f := range fields { fieldSet[f] = true } found := make(map[string]bool) fn := findFuncDecl(file, funcName) require.NotNil(t, fn, "function %s not found", funcName) ast.Inspect(fn.Body, func(n ast.Node) bool { switch v := n.(type) { case *ast.SelectorExpr: if fieldSet[v.Sel.Name] { found[v.Sel.Name] = true } case *ast.KeyValueExpr: if ident, ok := v.Key.(*ast.Ident); ok && fieldSet[ident.Name] { found[ident.Name] = true } } return true }) return found } // extractFuncGlobalRefs returns all identifier names referenced in the named function body. func extractFuncGlobalRefs(t *testing.T, file *ast.File, funcName string) map[string]bool { t.Helper() fn := findFuncDecl(file, funcName) require.NotNil(t, fn, "function %s not found", funcName) refs := make(map[string]bool) ast.Inspect(fn.Body, func(n ast.Node) bool { if ident, ok := n.(*ast.Ident); ok { refs[ident.Name] = true } return true }) return refs } func findFuncDecl(file *ast.File, name string) *ast.FuncDecl { for _, decl := range file.Decls { fn, ok := decl.(*ast.FuncDecl) if ok && fn.Name.Name == name { return fn } } return nil } // fieldToGlobalVar maps serviceParams field names to the package-level variable // names used in buildServiceArguments and applyServiceParams. func fieldToGlobalVar(field string) string { m := map[string]string{ "LogLevel": "logLevel", "DaemonAddr": "daemonAddr", "ManagementURL": "managementURL", "ConfigPath": "configPath", "LogFiles": "logFiles", "DisableProfiles": "profilesDisabled", "DisableUpdateSettings": "updateSettingsDisabled", "DisableNetworks": "networksDisabled", "ServiceEnvVars": "serviceEnvVars", } if v, ok := m[field]; ok { return v } // Default: lowercase first letter. return strings.ToLower(field[:1]) + field[1:] } func TestEnvMapToSlice(t *testing.T) { m := map[string]string{"A": "1", "B": "2"} s := envMapToSlice(m) assert.Len(t, s, 2) assert.Contains(t, s, "A=1") assert.Contains(t, s, "B=2") } func TestEnvMapToSlice_Empty(t *testing.T) { s := envMapToSlice(map[string]string{}) assert.Empty(t, s) }