diff --git a/cmd/deploy_test.go b/cmd/deploy_test.go index 6bd7574..71c6a6d 100644 --- a/cmd/deploy_test.go +++ b/cmd/deploy_test.go @@ -391,7 +391,7 @@ func TestApplyUserDefaults(t *testing.T) { t.Setenv("HOME", tmpDir) // For non-Unix systems. if !reflect.DeepEqual(tt.user, deployer.Config{}) { - configPath, err := paths.UserConfigPath() + configPath, err := paths.UserConfigPath(false) require.NoError(t, err) require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755)) data, err := yaml.Marshal(tt.user) @@ -415,7 +415,7 @@ func TestApplyUserDefaults(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", tmpDir) t.Setenv("HOME", tmpDir) // For non-Unix systems. - configPath, err := paths.UserConfigPath() + configPath, err := paths.UserConfigPath(false) require.NoError(t, err) require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755)) require.NoError(t, os.WriteFile(configPath, []byte(`invalid: [yaml`), 0o644)) diff --git a/cmd/main.go b/cmd/main.go index f664110..ffe30dd 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -40,7 +40,7 @@ func main() { // current config. This essentially means, that the user config can // override values, which are already initialized in NewConfig(). func tryApplyUserDefaults(log *logger.Logger, config *deployer.Config) error { - path, err := paths.UserConfigPath() + path, err := paths.UserConfigPath(true) if err != nil { return err } diff --git a/internal/paths/paths.go b/internal/paths/paths.go index d123c58..514cacc 100644 --- a/internal/paths/paths.go +++ b/internal/paths/paths.go @@ -8,7 +8,31 @@ import ( const appName = "roxie" -func UserConfigPath() (string, error) { +const envUserConfigPath = "ROXIE_USER_CONFIG_PATH" + +func UserConfigPath(validateUserConfig bool) (string, error) { + if overwrittenPath := os.Getenv(envUserConfigPath); overwrittenPath != "" { + if validateUserConfig { + if err := fileIsReadable(overwrittenPath); err != nil { + return "", fmt.Errorf("checking if user config file %q from environment variable %s is readable: %w", + overwrittenPath, envUserConfigPath, err) + } + } + return overwrittenPath, nil + } + return getDefaultUserConfigPath() +} + +func fileIsReadable(path string) error { + fd, err := os.Open(path) + if err != nil { + return err + } + _ = fd.Close() + return nil +} + +func getDefaultUserConfigPath() (string, error) { dir, err := configDir() if err != nil { return "", fmt.Errorf("retrieving user config path: %w", err) @@ -17,7 +41,7 @@ func UserConfigPath() (string, error) { } func UserConfigPathString() string { - path, err := UserConfigPath() + path, err := UserConfigPath(false) if err != nil { return "(UNAVAILABLE)" } diff --git a/internal/paths/paths_test.go b/internal/paths/paths_test.go new file mode 100644 index 0000000..d555fbe --- /dev/null +++ b/internal/paths/paths_test.go @@ -0,0 +1,147 @@ +package paths + +import ( + "fmt" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ( + defaultUserConfigPath = func() string { + // Ok to panic here. The tests would fail one way or another anyway. + path, err := getDefaultUserConfigPath() + if err != nil { + panic(fmt.Sprintf("failed to obtain default user config path: %v", err)) + } + return path + }() +) + +func TestUserConfigPath(t *testing.T) { + existentUserConfig := createTempFile(t, "cfg.yaml") + tests := []struct { + name string + envValue string + setEnv bool + expected string + validateUserConfig bool + expectErr bool + }{ + { + name: "returns env var value when set and file exists with validation", + envValue: existentUserConfig, + setEnv: true, + expected: existentUserConfig, + validateUserConfig: true, + }, + { + name: "validation returns error when env var value set to non-existent file", + envValue: "/non-existent/roxie.yaml", + setEnv: true, + validateUserConfig: true, + expectErr: true, + }, + { + name: "returns env var value when set to non-existent file with validation off", + envValue: "/non-existent/roxie.yaml", + setEnv: true, + expected: "/non-existent/roxie.yaml", + validateUserConfig: false, + }, + { + name: "returns default path when env var is not set with validation", + setEnv: false, + expected: defaultUserConfigPath, + validateUserConfig: true, + }, + { + name: "returns default path when env var is set but empty", + envValue: "", + setEnv: true, + expected: defaultUserConfigPath, + validateUserConfig: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanEnvironment(t) + + if tt.setEnv { + t.Setenv(envUserConfigPath, tt.envValue) + } + got, err := UserConfigPath(tt.validateUserConfig) + if tt.expectErr { + assert.Error(t, err, "expected UserConfigPath() to return error") + return + } + require.NoError(t, err) + assert.Equal(t, tt.expected, got) + }) + } +} + +func TestUserConfigPathString(t *testing.T) { + existentUserConfig := createTempFile(t, "cfg.yaml") + tests := []struct { + name string + envValue string + setEnv bool + expected string + }{ + { + name: "returns env var value when set and file does not exist", + envValue: "/nonexistent/path.yaml", + setEnv: true, + expected: "/nonexistent/path.yaml", + }, + { + name: "returns env var value when set and file exists", + envValue: existentUserConfig, + setEnv: true, + expected: existentUserConfig, + }, + { + name: "returns default path when env var is not set", + setEnv: false, + expected: defaultUserConfigPath, + }, + { + name: "returns default path when env var set but empty", + envValue: "", + setEnv: true, + expected: defaultUserConfigPath, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanEnvironment(t) + + if tt.setEnv { + t.Setenv(envUserConfigPath, tt.envValue) + } + assert.Equal(t, tt.expected, UserConfigPathString()) + }) + } +} + +func createTempFile(t *testing.T, name string) string { + tmpdir := t.TempDir() + path := tmpdir + "/" + name + err := os.WriteFile(path, nil, 0o600) + require.NoErrorf(t, err, "failed to create temporary file %q", path) + return path +} + +func cleanEnvironment(t *testing.T) { + // Clean environment for the duration of this test case. + // The os.Unsetenv() in combination with the cleanup handler installed by t.Setenv() + // guarantees that for the duration of the test, there is *no* variable set in the + // environment at all, not even an empty one. + t.Setenv(envUserConfigPath, "") + os.Unsetenv(envUserConfigPath) +}