840 lines
26 KiB
Go
840 lines
26 KiB
Go
package config
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
fileencoding "reasonix/internal/fileutil/encoding"
|
|
)
|
|
|
|
func TestLoadDotEnvDoesNotImportProjectOrHomeEnv(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
home := t.TempDir()
|
|
|
|
if err := os.WriteFile(filepath.Join(cwd, ".env"), []byte("KEY_CWD=from_cwd\nKEY_SHARED=cwd_wins\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(home, ".env"), []byte("KEY_HOME=from_home\nKEY_SHARED=home_loses\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
t.Chdir(cwd)
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
|
|
t.Setenv("USERPROFILE", home) // os.UserHomeDir reads HOME on Unix and USERPROFILE on Windows.
|
|
|
|
// Start clean so the file values are what land (Setenv auto-restores).
|
|
t.Setenv("KEY_CWD", "")
|
|
os.Unsetenv("KEY_CWD")
|
|
t.Setenv("KEY_HOME", "")
|
|
os.Unsetenv("KEY_HOME")
|
|
t.Setenv("KEY_SHARED", "")
|
|
os.Unsetenv("KEY_SHARED")
|
|
|
|
loadDotEnv()
|
|
|
|
if got := os.Getenv("KEY_CWD"); got != "" {
|
|
t.Errorf("project .env key was imported into process env: KEY_CWD=%q", got)
|
|
}
|
|
if got := os.Getenv("KEY_HOME"); got != "" {
|
|
t.Errorf("home .env key was loaded: KEY_HOME=%q", got)
|
|
}
|
|
if got := os.Getenv("KEY_SHARED"); got != "" {
|
|
t.Errorf("project/home .env shared key was imported: KEY_SHARED=%q", got)
|
|
}
|
|
}
|
|
|
|
// TestLoadDotEnvReadsGlobalCredentials proves `reasonix setup`'s target — the
|
|
// reasonix-owned credentials file under Reasonix home — is loaded from any
|
|
// working directory and wins over a project ./.env on a shared key.
|
|
func TestLoadDotEnvReadsGlobalCredentials(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
cfgHome := t.TempDir()
|
|
|
|
t.Chdir(cwd)
|
|
t.Setenv("HOME", cfgHome)
|
|
t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
|
|
t.Setenv("USERPROFILE", cfgHome)
|
|
t.Setenv("XDG_CONFIG_HOME", filepath.Join(cfgHome, ".config"))
|
|
t.Setenv("AppData", filepath.Join(cfgHome, "AppData"))
|
|
|
|
cred := UserCredentialsPath()
|
|
if cred == "" {
|
|
t.Skip("user config dir unresolved on this platform")
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(cred), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(cred, []byte("KEY_GLOBAL=from_credentials\nKEY_SHARED=global_wins\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(cwd, ".env"), []byte("KEY_SHARED=cwd_wins\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
t.Setenv("KEY_GLOBAL", "")
|
|
os.Unsetenv("KEY_GLOBAL")
|
|
t.Setenv("KEY_SHARED", "")
|
|
os.Unsetenv("KEY_SHARED")
|
|
|
|
loadDotEnv()
|
|
|
|
if got := os.Getenv("KEY_GLOBAL"); got != "from_credentials" {
|
|
t.Errorf("global credentials not loaded: KEY_GLOBAL=%q want from_credentials", got)
|
|
}
|
|
if got := os.Getenv("KEY_SHARED"); got != "global_wins" {
|
|
t.Errorf("global credentials should win over project .env: KEY_SHARED=%q want global_wins", got)
|
|
}
|
|
}
|
|
|
|
func TestLoadDotEnvDecodesGB18030Credentials(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("USERPROFILE", home)
|
|
t.Setenv("REASONIX_HOME", filepath.Join(home, "reasonix-home"))
|
|
t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
|
|
t.Setenv("PINNED_CN", "")
|
|
os.Unsetenv("PINNED_CN")
|
|
|
|
cred := UserCredentialsPath()
|
|
if err := os.MkdirAll(filepath.Dir(cred), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(cred, fileencoding.Encode("PINNED_CN=中文\n", fileencoding.GB18030), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
loadDotEnv()
|
|
if got := os.Getenv("PINNED_CN"); got != "中文" {
|
|
t.Fatalf("PINNED_CN = %q, want decoded Chinese value", got)
|
|
}
|
|
}
|
|
|
|
func TestLoadForRootExpandsPluginAuthFromProjectDotEnv(t *testing.T) {
|
|
project := t.TempDir()
|
|
cfgHome := t.TempDir()
|
|
|
|
t.Setenv("HOME", cfgHome)
|
|
t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
|
|
t.Setenv("USERPROFILE", cfgHome)
|
|
t.Setenv("XDG_CONFIG_HOME", filepath.Join(cfgHome, ".config"))
|
|
t.Setenv("AppData", filepath.Join(cfgHome, "AppData"))
|
|
t.Setenv("STRIPE_KEY", "")
|
|
os.Unsetenv("STRIPE_KEY")
|
|
|
|
if err := os.WriteFile(filepath.Join(project, ".env"), []byte("STRIPE_KEY=project-token\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(project, ".mcp.json"), []byte(`{
|
|
"mcpServers": {
|
|
"stripe": {
|
|
"type": "http",
|
|
"url": "https://mcp.example.test",
|
|
"headers": { "Authorization": "Bearer ${STRIPE_KEY}" }
|
|
}
|
|
}
|
|
}`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cfg, err := LoadForRoot(project)
|
|
if err != nil {
|
|
t.Fatalf("LoadForRoot: %v", err)
|
|
}
|
|
if len(cfg.Plugins) != 1 {
|
|
t.Fatalf("plugins = %+v", cfg.Plugins)
|
|
}
|
|
got := cfg.Plugins[0].ExpandedPlugin().Headers["Authorization"]
|
|
if got != "Bearer project-token" {
|
|
t.Fatalf("expanded auth header = %q, want project token", got)
|
|
}
|
|
if got := os.Getenv("STRIPE_KEY"); got != "" {
|
|
t.Fatalf("project .env leaked into process env: STRIPE_KEY=%q", got)
|
|
}
|
|
}
|
|
|
|
func TestProjectDotEnvUsesDotenvSyntax(t *testing.T) {
|
|
project := t.TempDir()
|
|
cfgHome := t.TempDir()
|
|
|
|
t.Setenv("HOME", cfgHome)
|
|
t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
|
|
t.Setenv("USERPROFILE", cfgHome)
|
|
t.Setenv("XDG_CONFIG_HOME", filepath.Join(cfgHome, ".config"))
|
|
t.Setenv("AppData", filepath.Join(cfgHome, "AppData"))
|
|
t.Setenv("PLUGIN_TOKEN", "")
|
|
os.Unsetenv("PLUGIN_TOKEN")
|
|
|
|
if err := os.WriteFile(filepath.Join(project, ".env"), []byte("export PLUGIN_TOKEN='project token # kept'\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(project, ".mcp.json"), []byte(`{
|
|
"mcpServers": {
|
|
"svc": {
|
|
"type": "http",
|
|
"url": "https://mcp.example.test",
|
|
"headers": { "Authorization": "Bearer ${PLUGIN_TOKEN}" }
|
|
}
|
|
}
|
|
}`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cfg, err := LoadForRoot(project)
|
|
if err != nil {
|
|
t.Fatalf("LoadForRoot: %v", err)
|
|
}
|
|
got := cfg.Plugins[0].ExpandedPlugin().Headers["Authorization"]
|
|
if got != "Bearer project token # kept" {
|
|
t.Fatalf("expanded auth header = %q", got)
|
|
}
|
|
if got := os.Getenv("PLUGIN_TOKEN"); got != "" {
|
|
t.Fatalf("project .env leaked into process env: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestDotEnvFileWarningsReportDuplicateKeysWithoutValues(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), ".env")
|
|
if err := os.WriteFile(path, []byte("TOKEN=secret-one\nOTHER=ok\nexport TOKEN='secret two'\nOTHER=last\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
file, ok := readDotEnvFile(path)
|
|
if !ok {
|
|
t.Fatal("readDotEnvFile failed")
|
|
}
|
|
if got := strings.Join(file.Duplicates, ","); got != "OTHER,TOKEN" {
|
|
t.Fatalf("Duplicates = %#v", file.Duplicates)
|
|
}
|
|
warnings := strings.Join(file.warnings(), "\n")
|
|
for _, want := range []string{"duplicate .env key OTHER", "duplicate .env key TOKEN", "last parsed value wins"} {
|
|
if !strings.Contains(warnings, want) {
|
|
t.Fatalf("warnings missing %q:\n%s", want, warnings)
|
|
}
|
|
}
|
|
for _, leak := range []string{"secret-one", "secret two"} {
|
|
if strings.Contains(warnings, leak) {
|
|
t.Fatalf("warnings leaked secret value %q:\n%s", leak, warnings)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDotEnvFileFilteredPreservesProjectScopeRules(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), ".env")
|
|
if err := os.WriteFile(path, []byte("PLUGIN_TOKEN=project\nREASONIX_HOME=blocked\nHOME=blocked\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
file, ok := readDotEnvFile(path)
|
|
if !ok {
|
|
t.Fatal("readDotEnvFile failed")
|
|
}
|
|
got := file.filtered(func(key string) bool { return !isProjectDotEnvControlKey(key) })
|
|
if got["PLUGIN_TOKEN"] != "project" {
|
|
t.Fatalf("PLUGIN_TOKEN = %q", got["PLUGIN_TOKEN"])
|
|
}
|
|
if _, ok := got["REASONIX_HOME"]; ok {
|
|
t.Fatalf("REASONIX_HOME should be filtered: %+v", got)
|
|
}
|
|
if _, ok := got["HOME"]; ok {
|
|
t.Fatalf("HOME should be filtered: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestLoadForRootScopesProjectDotEnvPerWorkspace(t *testing.T) {
|
|
home := t.TempDir()
|
|
projectA := t.TempDir()
|
|
projectB := t.TempDir()
|
|
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
|
|
t.Setenv("USERPROFILE", home)
|
|
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
|
t.Setenv("AppData", filepath.Join(home, "AppData"))
|
|
t.Setenv("SHARED_TOKEN", "")
|
|
os.Unsetenv("SHARED_TOKEN")
|
|
|
|
for _, tc := range []struct {
|
|
root string
|
|
value string
|
|
}{
|
|
{projectA, "token-a"},
|
|
{projectB, "token-b"},
|
|
} {
|
|
if err := os.WriteFile(filepath.Join(tc.root, ".env"), []byte("SHARED_TOKEN="+tc.value+"\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(tc.root, ".mcp.json"), []byte(`{
|
|
"mcpServers": {
|
|
"svc": {
|
|
"type": "http",
|
|
"url": "https://mcp.example.test",
|
|
"headers": { "Authorization": "Bearer ${SHARED_TOKEN}" }
|
|
}
|
|
}
|
|
}`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
cfgA, err := LoadForRoot(projectA)
|
|
if err != nil {
|
|
t.Fatalf("LoadForRoot A: %v", err)
|
|
}
|
|
cfgB, err := LoadForRoot(projectB)
|
|
if err != nil {
|
|
t.Fatalf("LoadForRoot B: %v", err)
|
|
}
|
|
if got := cfgA.Plugins[0].ExpandedPlugin().Headers["Authorization"]; got != "Bearer token-a" {
|
|
t.Fatalf("project A auth = %q, want token-a", got)
|
|
}
|
|
if got := cfgB.Plugins[0].ExpandedPlugin().Headers["Authorization"]; got != "Bearer token-b" {
|
|
t.Fatalf("project B auth = %q, want token-b", got)
|
|
}
|
|
if got := os.Getenv("SHARED_TOKEN"); got != "" {
|
|
t.Fatalf("project token leaked into process env: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestLoadForRootFiltersProjectDotEnvControlVars(t *testing.T) {
|
|
home := t.TempDir()
|
|
project := t.TempDir()
|
|
redirect := filepath.Join(project, "state")
|
|
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
|
|
t.Setenv("USERPROFILE", home)
|
|
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
|
t.Setenv("AppData", filepath.Join(home, "AppData"))
|
|
t.Setenv("REASONIX_STATE_HOME", "")
|
|
os.Unsetenv("REASONIX_STATE_HOME")
|
|
|
|
wantCred := UserCredentialsPath()
|
|
if wantCred == "" {
|
|
t.Skip("user credentials path unavailable")
|
|
}
|
|
if err := os.WriteFile(filepath.Join(project, ".env"), []byte("REASONIX_STATE_HOME="+redirect+"\nPLUGIN_TOKEN=project-token\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(project, ".mcp.json"), []byte(`{
|
|
"mcpServers": {
|
|
"svc": {
|
|
"type": "http",
|
|
"url": "https://mcp.example.test",
|
|
"headers": {
|
|
"Authorization": "Bearer ${PLUGIN_TOKEN}",
|
|
"State": "${REASONIX_STATE_HOME:-default-state}"
|
|
}
|
|
}
|
|
}
|
|
}`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cfg, err := LoadForRoot(project)
|
|
if err != nil {
|
|
t.Fatalf("LoadForRoot: %v", err)
|
|
}
|
|
headers := cfg.Plugins[0].ExpandedPlugin().Headers
|
|
if got := headers["Authorization"]; got != "Bearer project-token" {
|
|
t.Fatalf("plugin token = %q, want project token", got)
|
|
}
|
|
if got := headers["State"]; got != "default-state" {
|
|
t.Fatalf("control var expansion = %q, want default-state", got)
|
|
}
|
|
if got := UserCredentialsPath(); got != wantCred {
|
|
t.Fatalf("project .env redirected credentials path: %q want %q", got, wantCred)
|
|
}
|
|
if got := os.Getenv("REASONIX_STATE_HOME"); got != "" {
|
|
t.Fatalf("project control var leaked into process env: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestLoadForRootResolvesProviderCredentialsOverInheritedEnv(t *testing.T) {
|
|
project := t.TempDir()
|
|
cfgHome := t.TempDir()
|
|
key := "KEY_PROVIDER_GLOBAL_PRIORITY"
|
|
|
|
t.Setenv("HOME", cfgHome)
|
|
t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
|
|
t.Setenv("USERPROFILE", cfgHome)
|
|
t.Setenv("XDG_CONFIG_HOME", filepath.Join(cfgHome, ".config"))
|
|
t.Setenv("AppData", filepath.Join(cfgHome, "AppData"))
|
|
t.Setenv(key, "from_env")
|
|
|
|
cred := UserCredentialsPath()
|
|
if cred == "" {
|
|
t.Skip("user config dir unresolved on this platform")
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(cred), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(cred, []byte(key+"=from_credentials\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
|
|
default_model = "custom/m"
|
|
[[providers]]
|
|
name = "custom"
|
|
kind = "openai"
|
|
base_url = "https://example.invalid/v1"
|
|
model = "m"
|
|
api_key_env = "`+key+`"
|
|
`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cfg, err := LoadForRoot(project)
|
|
if err != nil {
|
|
t.Fatalf("LoadForRoot: %v", err)
|
|
}
|
|
provider, ok := cfg.Provider("custom")
|
|
if !ok {
|
|
t.Fatalf("provider missing: %+v", cfg.Providers)
|
|
}
|
|
if got := provider.APIKey(); got != "from_credentials" {
|
|
t.Fatalf("provider API key = %q, want credentials value", got)
|
|
}
|
|
if got := os.Getenv(key); got != "from_credentials" {
|
|
t.Fatalf("process env = %q, want credentials value pinned over inherited env", got)
|
|
}
|
|
}
|
|
|
|
func TestLoadForRootIgnoresProjectProviderEnvAndInheritedEnv(t *testing.T) {
|
|
project := t.TempDir()
|
|
cfgHome := t.TempDir()
|
|
key := "KEY_PROVIDER_PROJECT_PRIORITY"
|
|
|
|
t.Setenv("HOME", cfgHome)
|
|
t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
|
|
t.Setenv("USERPROFILE", cfgHome)
|
|
t.Setenv("XDG_CONFIG_HOME", filepath.Join(cfgHome, ".config"))
|
|
t.Setenv("AppData", filepath.Join(cfgHome, "AppData"))
|
|
t.Setenv(key, "from_env")
|
|
|
|
if err := os.WriteFile(filepath.Join(project, ".env"), []byte(key+"=from_project\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
|
|
default_model = "custom/m"
|
|
[[providers]]
|
|
name = "custom"
|
|
kind = "openai"
|
|
base_url = "https://example.invalid/v1"
|
|
model = "m"
|
|
api_key_env = "`+key+`"
|
|
`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cfg, err := LoadForRoot(project)
|
|
if err != nil {
|
|
t.Fatalf("LoadForRoot: %v", err)
|
|
}
|
|
provider, ok := cfg.Provider("custom")
|
|
if !ok {
|
|
t.Fatalf("provider missing: %+v", cfg.Providers)
|
|
}
|
|
if got := provider.APIKey(); got != "" {
|
|
t.Fatalf("provider API key = %q, want no key without global credentials", got)
|
|
}
|
|
if got := os.Getenv(key); got != "from_env" {
|
|
t.Fatalf("process env = %q, want inherited env left untouched", got)
|
|
}
|
|
}
|
|
|
|
func TestResolveCredentialGlobalFirstDoesNotMutateProjectEnv(t *testing.T) {
|
|
project := t.TempDir()
|
|
home := t.TempDir()
|
|
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
|
|
t.Setenv("USERPROFILE", home)
|
|
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
|
t.Setenv("AppData", filepath.Join(home, "AppData"))
|
|
|
|
key := "KEY_GLOBAL_PRIORITY"
|
|
if err := os.MkdirAll(filepath.Dir(UserCredentialsPath()), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(UserCredentialsPath(), []byte(key+"=from_credentials\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(project, ".env"), []byte(key+"=from_project\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv(key, "")
|
|
os.Unsetenv(key)
|
|
t.Setenv(key, "from_project")
|
|
|
|
if got := os.Getenv(key); got != "from_project" {
|
|
t.Fatalf("precondition: existing env should be project value, got %q", got)
|
|
}
|
|
got := ResolveCredentialForRootGlobalFirst(project, key)
|
|
if got.Value != "from_credentials" || got.Source.Kind != CredentialSourceCredentials {
|
|
t.Fatalf("credential = %+v, want global credentials for settings display", got)
|
|
}
|
|
if env := os.Getenv(key); env != "from_project" {
|
|
t.Fatalf("global-first resolution mutated process env: %q", env)
|
|
}
|
|
}
|
|
|
|
func TestCredentialResolverCachesGlobalFirstLookups(t *testing.T) {
|
|
project := t.TempDir()
|
|
key := "KEY_SETTINGS_CACHE"
|
|
calls := 0
|
|
stubStoredCredentialValueForTest(t, func(got string) (string, CredentialSource, bool) {
|
|
calls++
|
|
if got != key {
|
|
t.Fatalf("stored credential lookup key = %q, want %q", got, key)
|
|
}
|
|
return "from_credentials", CredentialSource{Kind: CredentialSourceCredentials, Label: "Reasonix credentials"}, true
|
|
})
|
|
|
|
resolver := NewCredentialResolverForRoot(project)
|
|
first := resolver.ResolveGlobalFirst(key)
|
|
second := resolver.ResolveGlobalFirst(key)
|
|
|
|
if !first.Set || first.Value != "from_credentials" {
|
|
t.Fatalf("first credential = %+v, want stored credential", first)
|
|
}
|
|
if !second.Set || second.Value != "from_credentials" {
|
|
t.Fatalf("second credential = %+v, want cached stored credential", second)
|
|
}
|
|
if calls != 1 {
|
|
t.Fatalf("stored credential lookups = %d, want 1 for repeated key in one resolver", calls)
|
|
}
|
|
}
|
|
|
|
func stubStoredCredentialValueForTest(t *testing.T, fn func(string) (string, CredentialSource, bool)) {
|
|
t.Helper()
|
|
old := storedCredentialValueLookup
|
|
storedCredentialValueLookup = fn
|
|
t.Cleanup(func() {
|
|
storedCredentialValueLookup = old
|
|
})
|
|
}
|
|
|
|
func TestResolveCredentialSourceShowsCredentialsShadowingProjectEnv(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
cfgHome := t.TempDir()
|
|
|
|
t.Chdir(cwd)
|
|
t.Setenv("HOME", cfgHome)
|
|
t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
|
|
t.Setenv("USERPROFILE", cfgHome)
|
|
t.Setenv("XDG_CONFIG_HOME", filepath.Join(cfgHome, ".config"))
|
|
t.Setenv("AppData", filepath.Join(cfgHome, "AppData"))
|
|
|
|
key := "KEY_SOURCE_PROJECT"
|
|
cred := UserCredentialsPath()
|
|
if cred == "" {
|
|
t.Skip("user config dir unresolved on this platform")
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(cred), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(cred, []byte(key+"=from_credentials\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(cwd, ".env"), []byte(key+"=from_project\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv(key, "")
|
|
os.Unsetenv(key)
|
|
|
|
loadDotEnv()
|
|
|
|
got := ResolveCredentialForRoot(cwd, key)
|
|
if !got.Set || got.Source.Kind != CredentialSourceCredentials {
|
|
t.Fatalf("source = %+v set=%v, want Reasonix credentials", got.Source, got.Set)
|
|
}
|
|
foundProjectShadow := false
|
|
for _, source := range got.Shadowed {
|
|
if source.Kind == CredentialSourceProjectEnv {
|
|
foundProjectShadow = true
|
|
}
|
|
}
|
|
if !foundProjectShadow {
|
|
t.Fatalf("shadowed = %+v, want project .env shadowed by credentials", got.Shadowed)
|
|
}
|
|
}
|
|
|
|
func TestResolveCredentialSourceShowsCredentialsShadowingEmptyProjectEnv(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
cfgHome := t.TempDir()
|
|
|
|
t.Chdir(cwd)
|
|
t.Setenv("HOME", cfgHome)
|
|
t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
|
|
t.Setenv("USERPROFILE", cfgHome)
|
|
t.Setenv("XDG_CONFIG_HOME", filepath.Join(cfgHome, ".config"))
|
|
t.Setenv("AppData", filepath.Join(cfgHome, "AppData"))
|
|
|
|
key := "KEY_SOURCE_EMPTY_PROJECT"
|
|
cred := UserCredentialsPath()
|
|
if cred == "" {
|
|
t.Skip("user config dir unresolved on this platform")
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(cred), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(cred, []byte(key+"=from_credentials\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(cwd, ".env"), []byte(key+"=\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv(key, "")
|
|
os.Unsetenv(key)
|
|
|
|
if _, err := StoreCredentialLines([]string{key + "=from_credentials"}); err != nil {
|
|
t.Fatalf("StoreCredentialLines: %v", err)
|
|
}
|
|
|
|
got := ResolveCredentialForRoot(cwd, key)
|
|
foundProjectShadow := false
|
|
for _, source := range got.Shadowed {
|
|
if source.Kind == CredentialSourceProjectEnv {
|
|
foundProjectShadow = true
|
|
}
|
|
}
|
|
if !foundProjectShadow {
|
|
t.Fatalf("shadowed = %+v, want empty project .env shadowed by credentials", got.Shadowed)
|
|
}
|
|
}
|
|
|
|
func TestResolveCredentialSourceShowsCredentialsBeforeHomeEnv(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
cfgHome := t.TempDir()
|
|
|
|
t.Chdir(cwd)
|
|
t.Setenv("HOME", cfgHome)
|
|
t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
|
|
t.Setenv("USERPROFILE", cfgHome)
|
|
t.Setenv("XDG_CONFIG_HOME", filepath.Join(cfgHome, ".config"))
|
|
t.Setenv("AppData", filepath.Join(cfgHome, "AppData"))
|
|
|
|
key := "KEY_SOURCE_CREDENTIALS"
|
|
cred := UserCredentialsPath()
|
|
if cred == "" {
|
|
t.Skip("user config dir unresolved on this platform")
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(cred), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(cred, []byte(key+"=from_credentials\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(cfgHome, ".env"), []byte(key+"=from_home\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv(key, "")
|
|
os.Unsetenv(key)
|
|
|
|
loadDotEnv()
|
|
|
|
got := ResolveCredentialForRoot(cwd, key)
|
|
if !got.Set || got.Source.Kind != CredentialSourceCredentials {
|
|
t.Fatalf("source = %+v set=%v, want Reasonix credentials", got.Source, got.Set)
|
|
}
|
|
if got.Value != "from_credentials" {
|
|
t.Fatalf("value = %q, want credentials value", got.Value)
|
|
}
|
|
}
|
|
|
|
func TestStoreCredentialLinesFileMode(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("USERPROFILE", home)
|
|
t.Setenv("AppData", filepath.Join(home, "AppData"))
|
|
t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
|
|
t.Setenv("KEY_FILE_MODE", "")
|
|
os.Unsetenv("KEY_FILE_MODE")
|
|
|
|
target, err := StoreCredentialLines([]string{"KEY_FILE_MODE=from_file_store"})
|
|
if err != nil {
|
|
t.Fatalf("StoreCredentialLines: %v", err)
|
|
}
|
|
if target != UserCredentialsPath() {
|
|
t.Fatalf("target = %q, want %q", target, UserCredentialsPath())
|
|
}
|
|
data, err := os.ReadFile(UserCredentialsPath())
|
|
if err != nil {
|
|
t.Fatalf("read credentials file: %v", err)
|
|
}
|
|
if string(data) != "KEY_FILE_MODE=from_file_store\n" {
|
|
t.Fatalf("credentials file = %q", data)
|
|
}
|
|
if got := os.Getenv("KEY_FILE_MODE"); got != "from_file_store" {
|
|
t.Fatalf("process env = %q, want stored value", got)
|
|
}
|
|
}
|
|
|
|
func TestUserCredentialsPathIgnoresReasonixStateHome(t *testing.T) {
|
|
home := t.TempDir()
|
|
state := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("USERPROFILE", home)
|
|
t.Setenv("AppData", filepath.Join(home, "AppData"))
|
|
t.Setenv("REASONIX_HOME", filepath.Join(home, "reasonix-home"))
|
|
t.Setenv("REASONIX_STATE_HOME", state)
|
|
|
|
want := filepath.Join(home, "reasonix-home", ".env")
|
|
if got := UserCredentialsPath(); got != want {
|
|
t.Fatalf("UserCredentialsPath() = %q, want %q", got, want)
|
|
}
|
|
if strings.HasPrefix(UserCredentialsPath(), state) {
|
|
t.Fatalf("credentials path must not live under REASONIX_STATE_HOME: %q", UserCredentialsPath())
|
|
}
|
|
}
|
|
|
|
func TestRemoveCredentialMarksClearedAndSetRemovesMarker(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("USERPROFILE", home)
|
|
t.Setenv("AppData", filepath.Join(home, "AppData"))
|
|
t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
|
|
|
|
if _, err := SetCredential("KEY_REMOVE_MARKER", "old"); err != nil {
|
|
t.Fatalf("SetCredential old: %v", err)
|
|
}
|
|
if err := RemoveCredential("KEY_REMOVE_MARKER"); err != nil {
|
|
t.Fatalf("RemoveCredential: %v", err)
|
|
}
|
|
if CredentialStored("KEY_REMOVE_MARKER") {
|
|
t.Fatal("removed key should not be stored")
|
|
}
|
|
if !credentialCurrentStoreClearedKey("KEY_REMOVE_MARKER") {
|
|
t.Fatal("removed key should leave a cleared marker")
|
|
}
|
|
if _, err := SetCredential("KEY_REMOVE_MARKER", "new"); err != nil {
|
|
t.Fatalf("SetCredential new: %v", err)
|
|
}
|
|
data, err := os.ReadFile(UserCredentialsPath())
|
|
if err != nil {
|
|
t.Fatalf("read credentials: %v", err)
|
|
}
|
|
if got := string(data); got != "KEY_REMOVE_MARKER=new\n" {
|
|
t.Fatalf("credentials after re-set = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestStoreCredentialLinesRejectsUnsafeFileLines(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("USERPROFILE", home)
|
|
t.Setenv("AppData", filepath.Join(home, "AppData"))
|
|
t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
|
|
|
|
_, err := StoreCredentialLines([]string{
|
|
"VALID_KEY=kept",
|
|
"BAD-KEY=ignored",
|
|
"MULTILINE=first\nINJECTED=second",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("StoreCredentialLines: %v", err)
|
|
}
|
|
data, err := os.ReadFile(UserCredentialsPath())
|
|
if err != nil {
|
|
t.Fatalf("read credentials file: %v", err)
|
|
}
|
|
if string(data) != "VALID_KEY=kept\n" {
|
|
t.Fatalf("credentials file = %q", data)
|
|
}
|
|
if got := os.Getenv("INJECTED"); got != "" {
|
|
t.Fatalf("injected env was set: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestStoreCredentialLinesParsesDotenvQuotes(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("USERPROFILE", home)
|
|
t.Setenv("AppData", filepath.Join(home, "AppData"))
|
|
t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
|
|
|
|
_, err := StoreCredentialLines([]string{`export QUOTED_KEY="value with spaces # kept"`})
|
|
if err != nil {
|
|
t.Fatalf("StoreCredentialLines: %v", err)
|
|
}
|
|
data, err := os.ReadFile(UserCredentialsPath())
|
|
if err != nil {
|
|
t.Fatalf("read credentials file: %v", err)
|
|
}
|
|
if string(data) != "QUOTED_KEY=\"value with spaces # kept\"\n" {
|
|
t.Fatalf("credentials file = %q", data)
|
|
}
|
|
if got := os.Getenv("QUOTED_KEY"); got != "value with spaces # kept" {
|
|
t.Fatalf("env QUOTED_KEY = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestSetCredentialRejectsInvalidInput(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("USERPROFILE", home)
|
|
t.Setenv("AppData", filepath.Join(home, "AppData"))
|
|
t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
|
|
|
|
if _, err := SetCredential("BAD-KEY", "value"); err == nil {
|
|
t.Fatal("SetCredential accepted invalid key")
|
|
}
|
|
if _, err := SetCredential("VALID_KEY", "first\nsecond"); err == nil {
|
|
t.Fatal("SetCredential accepted newline value")
|
|
}
|
|
if _, err := os.Stat(UserCredentialsPath()); !os.IsNotExist(err) {
|
|
t.Fatalf("credentials file should not be created for rejected input, stat err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestProjectConfigCannotOverrideCredentialStoreMode(t *testing.T) {
|
|
home := t.TempDir()
|
|
project := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("USERPROFILE", home)
|
|
t.Setenv("REASONIX_CREDENTIALS_STORE", "")
|
|
os.Unsetenv("REASONIX_CREDENTIALS_STORE")
|
|
if err := os.MkdirAll(filepath.Dir(UserConfigPath()), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(UserConfigPath(), []byte(`credentials_store = "file"`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`credentials_store = "keyring"`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cfg, err := LoadForRoot(project)
|
|
if err != nil {
|
|
t.Fatalf("LoadForRoot: %v", err)
|
|
}
|
|
if cfg.CredentialsStore != CredentialsStoreFile {
|
|
t.Fatalf("CredentialsStore = %q, want file from user config", cfg.CredentialsStore)
|
|
}
|
|
}
|
|
|
|
// TestLoadDotEnvGlobalCredentialsOverrideEnv confirms Reasonix-owned global
|
|
// credentials beat inherited environment variables.
|
|
func TestLoadDotEnvGlobalCredentialsOverrideEnv(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
|
|
t.Setenv("USERPROFILE", home)
|
|
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
|
t.Setenv("AppData", filepath.Join(home, "AppData"))
|
|
t.Setenv("PINNED", "from_env")
|
|
if err := os.MkdirAll(filepath.Dir(UserCredentialsPath()), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(UserCredentialsPath(), []byte("PINNED=from_credentials\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
loadDotEnv()
|
|
|
|
if got := os.Getenv("PINNED"); got != "from_credentials" {
|
|
t.Errorf("global credentials must win over inherited env: PINNED=%q want from_credentials", got)
|
|
}
|
|
}
|