| 1 | package config |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "path/filepath" |
| 6 | "testing" |
| 7 | ) |
| 8 | |
| 9 | func TestLoadForRootKeepsOfficialProviderAliasesDistinct(t *testing.T) { |
| 10 | isolateUserConfigHome(t) |
| 11 | root := t.TempDir() |
| 12 | userPath := UserConfigPath() |
| 13 | if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil { |
| 14 | t.Fatal(err) |
| 15 | } |
| 16 | if err := os.WriteFile(userPath, []byte(` |
| 17 | config_version = 2 |
| 18 | default_model = "deepseek/deepseek-v4-flash" |
| 19 | |
| 20 | [desktop] |
| 21 | provider_access = ["deepseek"] |
| 22 | |
| 23 | [[providers]] |
| 24 | name = "deepseek" |
| 25 | kind = "openai" |
| 26 | base_url = "https://api.deepseek.com" |
| 27 | models = ["deepseek-v4-flash", "deepseek-v4-pro"] |
| 28 | default = "deepseek-v4-flash" |
| 29 | api_key_env = "USER_DEEPSEEK_KEY" |
| 30 | `), 0o644); err != nil { |
| 31 | t.Fatal(err) |
| 32 | } |
| 33 | if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte(` |
| 34 | [[providers]] |
| 35 | name = "deepseek-flash" |
| 36 | kind = "openai" |
| 37 | base_url = "https://api.deepseek.com" |
| 38 | model = "deepseek-v4-flash" |
| 39 | api_key_env = "PROJECT_DEEPSEEK_KEY" |
| 40 | effort = "max" |
| 41 | `), 0o644); err != nil { |
| 42 | t.Fatal(err) |
| 43 | } |
| 44 | approveWorkspace(t, root) |
| 45 | |
| 46 | cfg, err := LoadForRoot(root) |
| 47 | if err != nil { |
| 48 | t.Fatalf("LoadForRoot: %v", err) |
| 49 | } |
| 50 | userProvider, ok := cfg.Provider("deepseek") |
| 51 | if !ok { |
| 52 | t.Fatalf("user deepseek provider missing: %+v", cfg.Providers) |
| 53 | } |
| 54 | if userProvider.APIKeyEnv != "USER_DEEPSEEK_KEY" { |
| 55 | t.Fatalf("deepseek provider = %+v, want user provider preserved", userProvider) |
| 56 | } |
| 57 | projectProvider, ok := cfg.Provider("deepseek-flash") |
| 58 | if !ok { |
| 59 | t.Fatalf("project deepseek-flash provider missing: %+v", cfg.Providers) |
| 60 | } |
| 61 | if projectProvider.APIKeyEnv != "PROJECT_DEEPSEEK_KEY" || projectProvider.Effort != "max" { |
| 62 | t.Fatalf("deepseek-flash provider = %+v, want project provider preserved", projectProvider) |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | func TestLoadForRootKeepsUserProviderOverSameNamedProjectProvider(t *testing.T) { |
| 67 | isolateUserConfigHome(t) |
| 68 | root := t.TempDir() |
| 69 | userPath := UserConfigPath() |
| 70 | if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil { |
| 71 | t.Fatal(err) |
| 72 | } |
| 73 | if err := os.WriteFile(userPath, []byte(` |
| 74 | [[providers]] |
| 75 | name = "shared" |
| 76 | kind = "openai" |
| 77 | base_url = "https://global.example/v1" |
| 78 | model = "global-model" |
| 79 | api_key_env = "GLOBAL_SHARED_KEY" |
| 80 | `), 0o644); err != nil { |
| 81 | t.Fatal(err) |
| 82 | } |
| 83 | if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte(` |
| 84 | [[providers]] |
| 85 | name = "shared" |
| 86 | kind = "openai" |
| 87 | base_url = "https://project.example/v1" |
| 88 | model = "project-model" |
| 89 | api_key_env = "PROJECT_SHARED_KEY" |
| 90 | |
| 91 | [[providers]] |
| 92 | name = "project-only" |
| 93 | kind = "openai" |
| 94 | base_url = "https://project.example/v1" |
| 95 | model = "project-only-model" |
| 96 | api_key_env = "PROJECT_ONLY_KEY" |
| 97 | `), 0o644); err != nil { |
| 98 | t.Fatal(err) |
| 99 | } |
| 100 | approveWorkspace(t, root) |
| 101 | |
| 102 | cfg, err := LoadForRoot(root) |
| 103 | if err != nil { |
| 104 | t.Fatalf("LoadForRoot: %v", err) |
| 105 | } |
| 106 | shared, ok := cfg.Provider("shared") |
| 107 | if !ok { |
| 108 | t.Fatalf("shared provider missing: %+v", cfg.Providers) |
| 109 | } |
| 110 | if shared.BaseURL != "https://global.example/v1" || shared.APIKeyEnv != "GLOBAL_SHARED_KEY" || shared.Model != "global-model" { |
| 111 | t.Fatalf("shared provider = %+v, want global provider to win over project provider", shared) |
| 112 | } |
| 113 | if _, ok := cfg.Provider("project-only"); !ok { |
| 114 | t.Fatalf("project-only provider missing: %+v", cfg.Providers) |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | func TestLoadForRootResolvesProviderCredentialsOverInheritedEnv(t *testing.T) { |
| 119 | project := t.TempDir() |
| 120 | cfgHome := t.TempDir() |
| 121 | key := "KEY_PROVIDER_GLOBAL_PRIORITY" |
| 122 | |
| 123 | t.Setenv("HOME", cfgHome) |
| 124 | t.Setenv("REASONIX_CREDENTIALS_STORE", "file") |
| 125 | t.Setenv("USERPROFILE", cfgHome) |
| 126 | t.Setenv("XDG_CONFIG_HOME", filepath.Join(cfgHome, ".config")) |
| 127 | t.Setenv("AppData", filepath.Join(cfgHome, "AppData")) |
| 128 | t.Setenv(key, "from_env") |
| 129 | |
| 130 | cred := UserCredentialsPath() |
| 131 | if cred == "" { |
| 132 | t.Skip("user config dir unresolved on this platform") |
| 133 | } |
| 134 | if err := os.MkdirAll(filepath.Dir(cred), 0o755); err != nil { |
| 135 | t.Fatal(err) |
| 136 | } |
| 137 | if err := os.WriteFile(cred, []byte(key+"=from_credentials\n"), 0o600); err != nil { |
| 138 | t.Fatal(err) |
| 139 | } |
| 140 | if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(` |
| 141 | default_model = "custom/m" |
| 142 | [[providers]] |
| 143 | name = "custom" |
| 144 | kind = "openai" |
| 145 | base_url = "https://example.invalid/v1" |
| 146 | model = "m" |
| 147 | api_key_env = "`+key+`" |
| 148 | `), 0o644); err != nil { |
| 149 | t.Fatal(err) |
| 150 | } |
| 151 | approveWorkspace(t, project) |
| 152 | |
| 153 | cfg, err := LoadForRoot(project) |
| 154 | if err != nil { |
| 155 | t.Fatalf("LoadForRoot: %v", err) |
| 156 | } |
| 157 | provider, ok := cfg.Provider("custom") |
| 158 | if !ok { |
| 159 | t.Fatalf("provider missing: %+v", cfg.Providers) |
| 160 | } |
| 161 | if got := provider.APIKey(); got != "from_credentials" { |
| 162 | t.Fatalf("provider API key = %q, want credentials value", got) |
| 163 | } |
| 164 | if got := os.Getenv(key); got != "from_credentials" { |
| 165 | t.Fatalf("process env = %q, want credentials value pinned over inherited env", got) |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | func TestLoadForRootIgnoresProjectProviderEnvAndInheritedEnv(t *testing.T) { |
| 170 | project := t.TempDir() |
| 171 | cfgHome := t.TempDir() |
| 172 | key := "KEY_PROVIDER_PROJECT_PRIORITY" |
| 173 | |
| 174 | t.Setenv("HOME", cfgHome) |
| 175 | t.Setenv("REASONIX_CREDENTIALS_STORE", "file") |
| 176 | t.Setenv("USERPROFILE", cfgHome) |
| 177 | t.Setenv("XDG_CONFIG_HOME", filepath.Join(cfgHome, ".config")) |
| 178 | t.Setenv("AppData", filepath.Join(cfgHome, "AppData")) |
| 179 | t.Setenv(key, "from_env") |
| 180 | |
| 181 | if err := os.WriteFile(filepath.Join(project, ".env"), []byte(key+"=from_project\n"), 0o600); err != nil { |
| 182 | t.Fatal(err) |
| 183 | } |
| 184 | if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(` |
| 185 | default_model = "custom/m" |
| 186 | [[providers]] |
| 187 | name = "custom" |
| 188 | kind = "openai" |
| 189 | base_url = "https://example.invalid/v1" |
| 190 | model = "m" |
| 191 | api_key_env = "`+key+`" |
| 192 | `), 0o644); err != nil { |
| 193 | t.Fatal(err) |
| 194 | } |
| 195 | approveWorkspace(t, project) |
| 196 | |
| 197 | cfg, err := LoadForRoot(project) |
| 198 | if err != nil { |
| 199 | t.Fatalf("LoadForRoot: %v", err) |
| 200 | } |
| 201 | provider, ok := cfg.Provider("custom") |
| 202 | if !ok { |
| 203 | t.Fatalf("provider missing: %+v", cfg.Providers) |
| 204 | } |
| 205 | if got := provider.APIKey(); got != "" { |
| 206 | t.Fatalf("provider API key = %q, want no key without global credentials", got) |
| 207 | } |
| 208 | if got := os.Getenv(key); got != "from_env" { |
| 209 | t.Fatalf("process env = %q, want inherited env left untouched", got) |
| 210 | } |
| 211 | } |
| 212 |