package config_test import ( "os" "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sneak.berlin/go/webhooker/internal/config" ) // dotEnvKey is a throwaway variable name the .env tests write and // read, so they cannot disturb real configuration. const dotEnvKey = "WEBHOOKER_TEST_DOTENV_VALUE" // malformedDotEnv is a file godotenv cannot parse. The first line is // the realistic typo — a space where the `=` belongs — and the rest // make sure nothing downstream treats the file as salvageable line by // line. const malformedDotEnv = "PORT 19615\n" + "this is not = valid ! syntax\n" + "\"unclosed\n" // unsetDotEnvKey makes dotEnvKey genuinely absent for the duration of // the test and restores it afterwards. t.Setenv registers the restore; // the Unsetenv that follows is what the test actually needs, because a // variable set to the empty string is still present in os.Environ and // godotenv would refuse to overwrite it. func unsetDotEnvKey(t *testing.T) { t.Helper() t.Setenv(dotEnvKey, "placeholder") require.NoError(t, os.Unsetenv(dotEnvKey)) } // writeDotEnv writes contents to a .env file in a fresh temporary // directory and returns its path. func writeDotEnv(t *testing.T, contents string) string { t.Helper() path := filepath.Join(t.TempDir(), config.DotEnvPath) require.NoError(t, os.WriteFile(path, []byte(contents), 0o600)) return path } // TestLoadDotEnv_MissingFileIsFine pins the case most deployments are // in. The file is optional: it is a development convenience, and a // deployment that configures the environment directly must start // normally rather than be refused for a file it was never meant to // have. // //nolint:paralleltest // unsetDotEnvKey uses t.Setenv. func TestLoadDotEnv_MissingFileIsFine(t *testing.T) { unsetDotEnvKey(t) absent := filepath.Join(t.TempDir(), config.DotEnvPath) require.NoError(t, config.LoadDotEnvFileForTest(absent)) _, present := os.LookupEnv(dotEnvKey) assert.False(t, present, "nothing may be set from an absent file") } // TestLoadDotEnv_AppliesValues pins that a well-formed file still // reaches the environment, which is the whole reason the file is read // at all. // //nolint:paralleltest // unsetDotEnvKey uses t.Setenv. func TestLoadDotEnv_AppliesValues(t *testing.T) { unsetDotEnvKey(t) path := writeDotEnv(t, "# a comment\n"+dotEnvKey+"=from-dot-env\n") require.NoError(t, config.LoadDotEnvFileForTest(path)) assert.Equal(t, "from-dot-env", os.Getenv(dotEnvKey)) } // TestLoadDotEnv_RealEnvironmentWins pins that the file cannot // override a variable the process was actually started with. A // deployment that sets DATA_DIR in its unit file must not have it // silently replaced by a stale .env left in the working directory. func TestLoadDotEnv_RealEnvironmentWins(t *testing.T) { t.Setenv(dotEnvKey, "from-environment") path := writeDotEnv(t, dotEnvKey+"=from-dot-env\n") require.NoError(t, config.LoadDotEnvFileForTest(path)) assert.Equal(t, "from-environment", os.Getenv(dotEnvKey)) } // TestLoadDotEnv_MalformedFileAborts is the defect this fixes. One bad // line makes godotenv apply none of the file, so every variable in it // reverts to its default; the process used to start that way with no // log line naming the file at all. // //nolint:paralleltest // unsetDotEnvKey uses t.Setenv. func TestLoadDotEnv_MalformedFileAborts(t *testing.T) { unsetDotEnvKey(t) path := writeDotEnv( t, malformedDotEnv+dotEnvKey+"=from-dot-env\n", ) err := config.LoadDotEnvFileForTest(path) require.Error(t, err) require.ErrorIs(t, err, config.ErrDotEnvUnreadable) assert.Contains( t, err.Error(), config.DotEnvPath, "the failure must name the file it could not read", ) _, present := os.LookupEnv(dotEnvKey) assert.False( t, present, "a rejected file must apply nothing, not part of itself", ) } // TestLoadDotEnv_UnreadableFileAborts pins that only absence is // tolerated. A .env that exists but cannot be read is a file the // operator meant to be applied, so it fails like a malformed one // rather than being treated as though it were not there. func TestLoadDotEnv_UnreadableFileAborts(t *testing.T) { t.Parallel() // A directory in the file's place: open succeeds and the read // fails, which no umask or root-ness can turn back into success // the way a chmod could. path := filepath.Join(t.TempDir(), config.DotEnvPath) require.NoError(t, os.Mkdir(path, 0o750)) err := config.LoadDotEnvFileForTest(path) require.Error(t, err) require.ErrorIs(t, err, config.ErrDotEnvUnreadable) } // TestLoadDotEnv_ReadsTheWorkingDirectory pins the path LoadDotEnv // itself opens, which the tests above bypass. It is relative to the // process working directory, as it was under godotenv/autoload and as // the README documents. // //nolint:paralleltest // t.Chdir moves the whole process. func TestLoadDotEnv_ReadsTheWorkingDirectory(t *testing.T) { unsetDotEnvKey(t) dir := t.TempDir() require.NoError(t, os.WriteFile( filepath.Join(dir, config.DotEnvPath), []byte(dotEnvKey+"=from-working-directory\n"), 0o600, )) t.Chdir(dir) require.NoError(t, config.LoadDotEnv()) assert.Equal(t, "from-working-directory", os.Getenv(dotEnvKey)) }