diff --git a/.serverdata/mh/Game.ini b/.serverdata/mh/Game.ini index 2b9167a..14de959 100644 --- a/.serverdata/mh/Game.ini +++ b/.serverdata/mh/Game.ini @@ -54,4 +54,3 @@ ServerLagReportColor2= ChatFeedWebhookURL= ChatFeedColor= ServerPassword= - diff --git a/cmd/main.go b/cmd/main.go index 444f44f..80b197e 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -28,8 +28,8 @@ var saveParam string var profileParam string func init() { - flag.StringVar(&addressParam, "address", "localhost", "RCON address, excluding port") - flag.UintVar(&portParam, "port", 7778, "RCON port") + flag.StringVar(&addressParam, "address", config.DefaultAddr, "RCON address, excluding port") + flag.UintVar(&portParam, "port", config.DefaultPort, "RCON port") flag.StringVar(&passwordParam, "pw", "", "RCON password, if not provided will attempt to load from env variables, if unavailable will prompt") flag.UintVar(&logLevelParam, "log", logger.LevelWarning, "sets log level (syslog severity tiers) for execution") flag.StringVar(&inputCmdParam, "cmd", "", "command to execute, if provided will not enter into interactive mode") @@ -105,8 +105,17 @@ func Execute() { flag.Parse() logLevel := uint8(logLevelParam) logger.Setup(logLevel) - - resolvedAddress, resolvedPort, resolvedPassword, err := config.Resolve(profileParam, addressParam, portParam, passwordParam) + configBasePath, err := os.UserConfigDir() + if err != nil { + logger.Critical.Fatal(err) + } + resolvedAddress, resolvedPort, resolvedPassword, err := config.Resolve( + configBasePath, + profileParam, + addressParam, + portParam, + passwordParam, + ) if err != nil { logger.Critical.Fatal(err) } @@ -121,7 +130,7 @@ func Execute() { // TODO: consider moving to config lib if saveParam != "" { - cfg, loadErr := config.Load() + cfg, loadErr := config.Load(configBasePath) if loadErr != nil { logger.Critical.Fatal(errors.Join(errors.New("failed to load config for saving"), loadErr)) } @@ -142,10 +151,10 @@ func Execute() { } cfg.SetProfile(saveParam, newProfile) - if saveErr := cfg.Save(); saveErr != nil { + if saveErr := cfg.Save(configBasePath); saveErr != nil { logger.Critical.Fatal(errors.Join(errors.New("failed to save config file"), saveErr)) } - configFilePath, pathErr := config.GetConfigPath() + configFilePath, pathErr := config.BuildConfigPath(configBasePath) if pathErr != nil { logger.Critical.Fatal(errors.Join(errors.New("failed to get config file path for display"), pathErr)) } diff --git a/internal/config/config.go b/internal/config/config.go index f56015b..6127bdf 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,20 +22,22 @@ type Config struct { const ( configDirName = "tcprcon" configFileName = "config.json" + DefaultAddr = "localhost" + DefaultPort = 7778 ) -func GetConfigPath() (string, error) { - configDir, err := os.UserConfigDir() - if err != nil { - return "", err +func BuildConfigPath(basePath string) (string, error) { + if basePath == "" { + return "", ErrUndefinedConfigBasePath } - fullPath := filepath.Join(configDir, configDirName) + fullPath := filepath.Join(basePath, configDirName) return filepath.Join(fullPath, configFileName), nil } -func Load() (*Config, error) { - path, err := GetConfigPath() +func Load(baseConfigPath string) (*Config, error) { + + path, err := BuildConfigPath(baseConfigPath) if err != nil { return nil, err } @@ -62,8 +64,8 @@ func Load() (*Config, error) { return &cfg, nil } -func (source *Config) Save() error { - path, err := GetConfigPath() +func (source *Config) Save(configBasePath string) error { + path, err := BuildConfigPath(configBasePath) if err != nil { return err } @@ -97,8 +99,8 @@ func (source *Config) SetProfile(name string, p Profile) { source.Profiles[name] = p } -func Resolve(profileName string, addrFlag string, portFlag uint, pwFlag string) (string, uint, string, error) { - cfg, err := Load() +func Resolve(configBasePath string, profileName string, addrFlag string, portFlag uint, pwFlag string) (string, uint, string, error) { + cfg, err := Load(configBasePath) if err != nil { return "", 0, "", err } @@ -116,10 +118,10 @@ func Resolve(profileName string, addrFlag string, portFlag uint, pwFlag string) // only override if the flags are still at their default values // NOTE: this logic assumes defaults are "localhost", 7778, and "" // TODO: this "default" handling can introduce bugs, rethink this at some point - if finalAddr == "localhost" && p.Address != "" { + if finalAddr == DefaultAddr && p.Address != "" { finalAddr = p.Address } - if finalPort == 7778 && p.Port != 0 { + if finalPort == DefaultPort && p.Port != 0 { finalPort = p.Port } if finalPw == "" && p.Password != "" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..eae3ca1 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,291 @@ +package config + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" +) + +func TestShouldLoadWhenFileExists(t *testing.T) { + sourceProfile := Profile{ + Address: "localhost", + Port: 7778, + Password: "localpassword", + } + sourceConfig := Config{ + Profiles: map[string]Profile{ + "docker": sourceProfile, + }, + } + baseConfigPath := t.TempDir() + configFolder := filepath.Join(baseConfigPath, configDirName) + _ = os.Mkdir(configFolder, 0700) + jsonStr, _ := json.MarshalIndent(sourceConfig, "", " ") + os.WriteFile( + filepath.Join(configFolder, configFileName), + []byte(jsonStr), + 0600, + ) + + cfg, err := Load(baseConfigPath) + if err != nil { + t.Fatal(errors.Join(errors.New("unexpected error loading config"), err)) + } + if cfg == nil { + t.Fatal("unexpected nil config") + } + profile, exists := cfg.GetProfile("docker") + if !exists { + t.Fatal("unexpected profile (docker) absence in loaded config") + } + if profile != sourceProfile { + t.Fatalf("unexpected mismatching loaded data, expected: %+v; received: %+v", sourceProfile, profile) + } +} + +func TestShouldSaveToConfig(t *testing.T) { + baseConfigPath := t.TempDir() + sourceProfile := Profile{ + Address: "localhost", + Port: 7778, + Password: "localpassword", + } + sourceConfig := Config{ + Profiles: map[string]Profile{ + "docker": sourceProfile, + }, + } + sourceConfig.Save(baseConfigPath) + filePath := filepath.Join(baseConfigPath, configDirName, configFileName) + fileInfo, err := os.Stat(filePath) + if err != nil { + t.Fatal(errors.Join(errors.New("unexpected file stat error"), err)) + } + if fileMode := fileInfo.Mode(); fileMode.Perm() != 0600 { + t.Errorf("unxpected file mode: %v, expected 0600", fileMode) + } + fileBytes, err := os.ReadFile(filePath) + if err != nil { + t.Fatal(errors.Join(errors.New("unexpected file read error"), err)) + } + var cfg Config + err = json.Unmarshal(fileBytes, &cfg) + if err != nil { + t.Fatal(errors.Join(errors.New("unexpected json unmarshal error"), err)) + } + profile, exists := cfg.GetProfile("docker") + if !exists { + t.Fatal("unexpected profile (docker) absence in loaded config") + } + if profile != sourceProfile { + t.Fatalf("unexpected mismatching loaded data, expected: %+v; received: %+v", sourceProfile, profile) + } +} + +func TestShouldReturnErrorWhenBasePathIsEmpty(t *testing.T) { + _, err := Load("") + if err == nil { + t.Fatal("expected error for empty base path, got nil") + } + if !errors.Is(err, ErrUndefinedConfigBasePath) { + t.Fatalf("expected ErrUndefinedConfigBasePath, got %v", err) + } +} + +func TestSaveCreatesConfigDirIfMissing(t *testing.T) { + baseConfigPath := t.TempDir() + sourceProfile := Profile{ + Address: "localhost", + Port: 7778, + Password: "localpassword", + } + sourceConfig := Config{ + Profiles: map[string]Profile{ + "docker": sourceProfile, + }, + } + + err := sourceConfig.Save(baseConfigPath) + if err != nil { + t.Fatal(errors.Join(errors.New("unexpected save error"), err)) + } + + configDirPath := filepath.Join(baseConfigPath, configDirName) + dirInfo, err := os.Stat(configDirPath) + if err != nil { + t.Fatal(errors.Join(errors.New("unexpected dir stat error"), err)) + } + if !dirInfo.IsDir() { + t.Fatal("expected config dir to be a directory") + } + if dirInfo.Mode().Perm() != 0700 { + t.Errorf("unexpected dir mode: %v, expected 0700", dirInfo.Mode()) + } + + filePath := filepath.Join(configDirPath, configFileName) + if _, err := os.Stat(filePath); os.IsNotExist(err) { + t.Fatal("expected config file to exist after Save") + } +} + +func TestSaveOverwritesExistingProfile(t *testing.T) { + baseConfigPath := t.TempDir() + firstProfile := Profile{ + Address: "localhost", + Port: 7778, + Password: "firstpassword", + } + cfg := Config{ + Profiles: map[string]Profile{ + "myserver": firstProfile, + }, + } + cfg.Save(baseConfigPath) + + updatedProfile := Profile{ + Address: "192.168.1.50", + Port: 9000, + Password: "updatedpassword", + } + cfg.SetProfile("myserver", updatedProfile) + err := cfg.Save(baseConfigPath) + if err != nil { + t.Fatal(errors.Join(errors.New("unexpected save error"), err)) + } + + loaded, err := Load(baseConfigPath) + if err != nil { + t.Fatal(errors.Join(errors.New("unexpected load error"), err)) + } + profile, exists := loaded.GetProfile("myserver") + if !exists { + t.Fatal("expected profile 'myserver' to exist after overwrite") + } + if profile != updatedProfile { + t.Fatalf("expected updated profile %+v, got %+v", updatedProfile, profile) + } +} + +func TestResolveShouldLoadFromConfig(t *testing.T) { + baseConfigPath := t.TempDir() + sourceProfile := Profile{ + Address: "169.230.184.1", + Port: 7482, + Password: "mycloset", + } + sourceConfig := Config{ + Profiles: map[string]Profile{ + "pleyades": sourceProfile, + }, + } + sourceConfig.Save(baseConfigPath) + resolvedAddress, resolvedPort, resolvedPassword, err := Resolve( + baseConfigPath, + "pleyades", + DefaultAddr, + DefaultPort, + "", + ) + + if err != nil { + t.Fatal(errors.Join(errors.New("unexpected load error"), err)) + } + + if resolvedAddress != sourceProfile.Address { + t.Errorf("expected address %s, but got %s", sourceProfile.Address, resolvedAddress) + } + if resolvedPort != sourceProfile.Port { + t.Errorf("expected port %d, but got %d", sourceProfile.Port, resolvedPort) + } + if resolvedPassword != sourceProfile.Password { + t.Errorf("expected password %s, but got %s", sourceProfile.Password, resolvedPassword) + } +} + +func TestResolveShouldNotOverrideExplicitFlags(t *testing.T) { + baseConfigPath := t.TempDir() + sourceProfile := Profile{ + Address: "169.230.184.1", + Port: 7482, + Password: "profilepassword", + } + sourceConfig := Config{ + Profiles: map[string]Profile{ + "pleyades": sourceProfile, + }, + } + sourceConfig.Save(baseConfigPath) + + explicitAddr := "10.0.0.1" + explicitPort := uint(9999) + explicitPw := "explicitpassword" + + resolvedAddress, resolvedPort, resolvedPassword, err := Resolve( + baseConfigPath, + "pleyades", + explicitAddr, + explicitPort, + explicitPw, + ) + if err != nil { + t.Fatal(errors.Join(errors.New("unexpected resolve error"), err)) + } + if resolvedAddress != explicitAddr { + t.Errorf("expected explicit address %s, but got %s", explicitAddr, resolvedAddress) + } + if resolvedPort != explicitPort { + t.Errorf("expected explicit port %d, but got %d", explicitPort, resolvedPort) + } + if resolvedPassword != explicitPw { + t.Errorf("expected explicit password %s, but got %s", explicitPw, resolvedPassword) + } +} + +func TestResolveShouldReturnErrorForMissingProfile(t *testing.T) { + baseConfigPath := t.TempDir() + emptyConfig := Config{ + Profiles: map[string]Profile{}, + } + emptyConfig.Save(baseConfigPath) + + _, _, _, err := Resolve(baseConfigPath, "nonexistent", DefaultAddr, DefaultPort, "") + if err == nil { + t.Fatal("expected error for missing profile, got nil") + } +} + +func TestResolveShouldReturnDefaultsWhenNoProfile(t *testing.T) { + baseConfigPath := t.TempDir() + + resolvedAddress, resolvedPort, resolvedPassword, err := Resolve( + baseConfigPath, + "", + DefaultAddr, + DefaultPort, + "", + ) + if err != nil { + t.Fatal(errors.Join(errors.New("unexpected resolve error"), err)) + } + if resolvedAddress != DefaultAddr { + t.Errorf("expected default address %s, but got %s", DefaultAddr, resolvedAddress) + } + if resolvedPort != DefaultPort { + t.Errorf("expected default port %d, but got %d", DefaultPort, resolvedPort) + } + if resolvedPassword != "" { + t.Errorf("expected empty password, but got %s", resolvedPassword) + } +} + +func TestBuildConfigPathReturnsErrorOnEmptyBase(t *testing.T) { + _, err := BuildConfigPath("") + if err == nil { + t.Fatal("expected error for empty base path, got nil") + } + if !errors.Is(err, ErrUndefinedConfigBasePath) { + t.Fatalf("expected ErrUndefinedConfigBasePath, got %v", err) + } +} diff --git a/internal/config/errors.go b/internal/config/errors.go new file mode 100644 index 0000000..d91a72e --- /dev/null +++ b/internal/config/errors.go @@ -0,0 +1,5 @@ +package config + +import "errors" + +var ErrUndefinedConfigBasePath error = errors.New("undefined config base path")