返回 DeepSeek-Reasonix
dotenv_test.go
根目录 / internal / config / dotenv_test.go
1 package config
2
3 import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8
9 fileencoding "reasonix/internal/fileutil/encoding"
10 )
11
12 func TestLoadDotEnvDoesNotImportProjectOrHomeEnv(t *testing.T) {
13 cwd := t.TempDir()
14 home := t.TempDir()
15
16 if err := os.WriteFile(filepath.Join(cwd, ".env"), []byte("KEY_CWD=from_cwd\nKEY_SHARED=cwd_wins\n"), 0o600); err != nil {
17 t.Fatal(err)
18 }
19 if err := os.WriteFile(filepath.Join(home, ".env"), []byte("KEY_HOME=from_home\nKEY_SHARED=home_loses\n"), 0o600); err != nil {
20 t.Fatal(err)
21 }
22
23 t.Chdir(cwd)
24 t.Setenv("HOME", home)
25 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
26 t.Setenv("USERPROFILE", home) // os.UserHomeDir reads HOME on Unix and USERPROFILE on Windows.
27
28 // Start clean so the file values are what land (Setenv auto-restores).
29 t.Setenv("KEY_CWD", "")
30 os.Unsetenv("KEY_CWD")
31 t.Setenv("KEY_HOME", "")
32 os.Unsetenv("KEY_HOME")
33 t.Setenv("KEY_SHARED", "")
34 os.Unsetenv("KEY_SHARED")
35
36 loadDotEnv()
37
38 if got := os.Getenv("KEY_CWD"); got != "" {
39 t.Errorf("project .env key was imported into process env: KEY_CWD=%q", got)
40 }
41 if got := os.Getenv("KEY_HOME"); got != "" {
42 t.Errorf("home .env key was loaded: KEY_HOME=%q", got)
43 }
44 if got := os.Getenv("KEY_SHARED"); got != "" {
45 t.Errorf("project/home .env shared key was imported: KEY_SHARED=%q", got)
46 }
47 }
48
49 // TestLoadDotEnvReadsGlobalCredentials proves `reasonix setup`'s target — the
50 // reasonix-owned credentials file under Reasonix home — is loaded from any
51 // working directory and wins over a project ./.env on a shared key.
52 func TestLoadDotEnvReadsGlobalCredentials(t *testing.T) {
53 cwd := t.TempDir()
54 cfgHome := t.TempDir()
55
56 t.Chdir(cwd)
57 t.Setenv("HOME", cfgHome)
58 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
59 t.Setenv("USERPROFILE", cfgHome)
60 t.Setenv("XDG_CONFIG_HOME", filepath.Join(cfgHome, ".config"))
61 t.Setenv("AppData", filepath.Join(cfgHome, "AppData"))
62
63 cred := UserCredentialsPath()
64 if cred == "" {
65 t.Skip("user config dir unresolved on this platform")
66 }
67 if err := os.MkdirAll(filepath.Dir(cred), 0o755); err != nil {
68 t.Fatal(err)
69 }
70 if err := os.WriteFile(cred, []byte("KEY_GLOBAL=from_credentials\nKEY_SHARED=global_wins\n"), 0o600); err != nil {
71 t.Fatal(err)
72 }
73 if err := os.WriteFile(filepath.Join(cwd, ".env"), []byte("KEY_SHARED=cwd_wins\n"), 0o600); err != nil {
74 t.Fatal(err)
75 }
76
77 t.Setenv("KEY_GLOBAL", "")
78 os.Unsetenv("KEY_GLOBAL")
79 t.Setenv("KEY_SHARED", "")
80 os.Unsetenv("KEY_SHARED")
81
82 loadDotEnv()
83
84 if got := os.Getenv("KEY_GLOBAL"); got != "from_credentials" {
85 t.Errorf("global credentials not loaded: KEY_GLOBAL=%q want from_credentials", got)
86 }
87 if got := os.Getenv("KEY_SHARED"); got != "global_wins" {
88 t.Errorf("global credentials should win over project .env: KEY_SHARED=%q want global_wins", got)
89 }
90 }
91
92 func TestCredentialEnvNamesIncludesUnconfiguredStoredKeys(t *testing.T) {
93 home := t.TempDir()
94 t.Setenv("REASONIX_HOME", filepath.Join(home, "reasonix-home"))
95
96 credentialPath := UserCredentialsPath()
97 if err := os.MkdirAll(filepath.Dir(credentialPath), 0o700); err != nil {
98 t.Fatal(err)
99 }
100 if err := os.WriteFile(credentialPath, []byte("CONFIGURED_PROVIDER_KEY=configured\nSTALE_PROVIDER_KEY=stale\n"), 0o600); err != nil {
101 t.Fatal(err)
102 }
103
104 cfg := &Config{Providers: []ProviderEntry{{APIKeyEnv: "CONFIGURED_PROVIDER_KEY"}}}
105 got := cfg.CredentialEnvNames()
106 for _, want := range []string{"CONFIGURED_PROVIDER_KEY", "STALE_PROVIDER_KEY"} {
107 if !containsString(got, want) {
108 t.Fatalf("credential env names = %v, missing %s", got, want)
109 }
110 }
111 }
112
113 func TestLoadDotEnvDecodesGB18030Credentials(t *testing.T) {
114 home := t.TempDir()
115 t.Setenv("HOME", home)
116 t.Setenv("USERPROFILE", home)
117 t.Setenv("REASONIX_HOME", filepath.Join(home, "reasonix-home"))
118 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
119 t.Setenv("PINNED_CN", "")
120 os.Unsetenv("PINNED_CN")
121
122 cred := UserCredentialsPath()
123 if err := os.MkdirAll(filepath.Dir(cred), 0o700); err != nil {
124 t.Fatal(err)
125 }
126 if err := os.WriteFile(cred, fileencoding.Encode("PINNED_CN=中文\n", fileencoding.GB18030), 0o600); err != nil {
127 t.Fatal(err)
128 }
129
130 loadDotEnv()
131 if got := os.Getenv("PINNED_CN"); got != "中文" {
132 t.Fatalf("PINNED_CN = %q, want decoded Chinese value", got)
133 }
134 }
135
136 func TestLoadForRootExpandsPluginAuthFromProjectDotEnv(t *testing.T) {
137 project := t.TempDir()
138 cfgHome := t.TempDir()
139
140 t.Setenv("HOME", cfgHome)
141 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
142 t.Setenv("USERPROFILE", cfgHome)
143 t.Setenv("XDG_CONFIG_HOME", filepath.Join(cfgHome, ".config"))
144 t.Setenv("AppData", filepath.Join(cfgHome, "AppData"))
145 t.Setenv("STRIPE_KEY", "")
146 os.Unsetenv("STRIPE_KEY")
147
148 if err := os.WriteFile(filepath.Join(project, ".env"), []byte("STRIPE_KEY=project-token\n"), 0o600); err != nil {
149 t.Fatal(err)
150 }
151 if err := os.WriteFile(filepath.Join(project, ".mcp.json"), []byte(`{
152 "mcpServers": {
153 "stripe": {
154 "type": "http",
155 "url": "https://mcp.example.test",
156 "headers": { "Authorization": "Bearer ${STRIPE_KEY}" }
157 }
158 }
159 }`), 0o644); err != nil {
160 t.Fatal(err)
161 }
162
163 cfg, err := LoadForRoot(project)
164 if err != nil {
165 t.Fatalf("LoadForRoot: %v", err)
166 }
167 if len(cfg.Plugins) != 1 {
168 t.Fatalf("plugins = %+v", cfg.Plugins)
169 }
170 got := cfg.Plugins[0].ExpandedPlugin().Headers["Authorization"]
171 if got != "Bearer project-token" {
172 t.Fatalf("expanded auth header = %q, want project token", got)
173 }
174 if got := os.Getenv("STRIPE_KEY"); got != "" {
175 t.Fatalf("project .env leaked into process env: STRIPE_KEY=%q", got)
176 }
177 }
178
179 func TestProjectDotEnvUsesDotenvSyntax(t *testing.T) {
180 project := t.TempDir()
181 cfgHome := t.TempDir()
182
183 t.Setenv("HOME", cfgHome)
184 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
185 t.Setenv("USERPROFILE", cfgHome)
186 t.Setenv("XDG_CONFIG_HOME", filepath.Join(cfgHome, ".config"))
187 t.Setenv("AppData", filepath.Join(cfgHome, "AppData"))
188 t.Setenv("PLUGIN_TOKEN", "")
189 os.Unsetenv("PLUGIN_TOKEN")
190
191 if err := os.WriteFile(filepath.Join(project, ".env"), []byte("export PLUGIN_TOKEN='project token # kept'\n"), 0o600); err != nil {
192 t.Fatal(err)
193 }
194 if err := os.WriteFile(filepath.Join(project, ".mcp.json"), []byte(`{
195 "mcpServers": {
196 "svc": {
197 "type": "http",
198 "url": "https://mcp.example.test",
199 "headers": { "Authorization": "Bearer ${PLUGIN_TOKEN}" }
200 }
201 }
202 }`), 0o644); err != nil {
203 t.Fatal(err)
204 }
205
206 cfg, err := LoadForRoot(project)
207 if err != nil {
208 t.Fatalf("LoadForRoot: %v", err)
209 }
210 got := cfg.Plugins[0].ExpandedPlugin().Headers["Authorization"]
211 if got != "Bearer project token # kept" {
212 t.Fatalf("expanded auth header = %q", got)
213 }
214 if got := os.Getenv("PLUGIN_TOKEN"); got != "" {
215 t.Fatalf("project .env leaked into process env: %q", got)
216 }
217 }
218
219 func TestDotEnvFileWarningsReportDuplicateKeysWithoutValues(t *testing.T) {
220 path := filepath.Join(t.TempDir(), ".env")
221 if err := os.WriteFile(path, []byte("TOKEN=secret-one\nOTHER=ok\nexport TOKEN='secret two'\nOTHER=last\n"), 0o600); err != nil {
222 t.Fatal(err)
223 }
224 file, ok := readDotEnvFile(path)
225 if !ok {
226 t.Fatal("readDotEnvFile failed")
227 }
228 if got := strings.Join(file.Duplicates, ","); got != "OTHER,TOKEN" {
229 t.Fatalf("Duplicates = %#v", file.Duplicates)
230 }
231 warnings := strings.Join(file.warnings(), "\n")
232 for _, want := range []string{"duplicate .env key OTHER", "duplicate .env key TOKEN", "last parsed value wins"} {
233 if !strings.Contains(warnings, want) {
234 t.Fatalf("warnings missing %q:\n%s", want, warnings)
235 }
236 }
237 for _, leak := range []string{"secret-one", "secret two"} {
238 if strings.Contains(warnings, leak) {
239 t.Fatalf("warnings leaked secret value %q:\n%s", leak, warnings)
240 }
241 }
242 }
243
244 func TestDotEnvFileFilteredPreservesProjectScopeRules(t *testing.T) {
245 path := filepath.Join(t.TempDir(), ".env")
246 if err := os.WriteFile(path, []byte("PLUGIN_TOKEN=project\nREASONIX_HOME=blocked\nHOME=blocked\n"), 0o600); err != nil {
247 t.Fatal(err)
248 }
249 file, ok := readDotEnvFile(path)
250 if !ok {
251 t.Fatal("readDotEnvFile failed")
252 }
253 got := file.filtered(func(key string) bool { return !isProjectDotEnvControlKey(key) })
254 if got["PLUGIN_TOKEN"] != "project" {
255 t.Fatalf("PLUGIN_TOKEN = %q", got["PLUGIN_TOKEN"])
256 }
257 if _, ok := got["REASONIX_HOME"]; ok {
258 t.Fatalf("REASONIX_HOME should be filtered: %+v", got)
259 }
260 if _, ok := got["HOME"]; ok {
261 t.Fatalf("HOME should be filtered: %+v", got)
262 }
263 }
264
265 func TestLoadForRootScopesProjectDotEnvPerWorkspace(t *testing.T) {
266 home := t.TempDir()
267 projectA := t.TempDir()
268 projectB := t.TempDir()
269
270 t.Setenv("HOME", home)
271 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
272 t.Setenv("USERPROFILE", home)
273 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
274 t.Setenv("AppData", filepath.Join(home, "AppData"))
275 t.Setenv("SHARED_TOKEN", "")
276 os.Unsetenv("SHARED_TOKEN")
277
278 for _, tc := range []struct {
279 root string
280 value string
281 }{
282 {projectA, "token-a"},
283 {projectB, "token-b"},
284 } {
285 if err := os.WriteFile(filepath.Join(tc.root, ".env"), []byte("SHARED_TOKEN="+tc.value+"\n"), 0o600); err != nil {
286 t.Fatal(err)
287 }
288 if err := os.WriteFile(filepath.Join(tc.root, ".mcp.json"), []byte(`{
289 "mcpServers": {
290 "svc": {
291 "type": "http",
292 "url": "https://mcp.example.test",
293 "headers": { "Authorization": "Bearer ${SHARED_TOKEN}" }
294 }
295 }
296 }`), 0o644); err != nil {
297 t.Fatal(err)
298 }
299 }
300
301 cfgA, err := LoadForRoot(projectA)
302 if err != nil {
303 t.Fatalf("LoadForRoot A: %v", err)
304 }
305 cfgB, err := LoadForRoot(projectB)
306 if err != nil {
307 t.Fatalf("LoadForRoot B: %v", err)
308 }
309 if got := cfgA.Plugins[0].ExpandedPlugin().Headers["Authorization"]; got != "Bearer token-a" {
310 t.Fatalf("project A auth = %q, want token-a", got)
311 }
312 if got := cfgB.Plugins[0].ExpandedPlugin().Headers["Authorization"]; got != "Bearer token-b" {
313 t.Fatalf("project B auth = %q, want token-b", got)
314 }
315 if got := os.Getenv("SHARED_TOKEN"); got != "" {
316 t.Fatalf("project token leaked into process env: %q", got)
317 }
318 }
319
320 func TestLoadForRootFiltersProjectDotEnvControlVars(t *testing.T) {
321 home := t.TempDir()
322 project := t.TempDir()
323 redirect := filepath.Join(project, "state")
324
325 t.Setenv("HOME", home)
326 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
327 t.Setenv("USERPROFILE", home)
328 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
329 t.Setenv("AppData", filepath.Join(home, "AppData"))
330 t.Setenv("REASONIX_STATE_HOME", "")
331 os.Unsetenv("REASONIX_STATE_HOME")
332
333 wantCred := UserCredentialsPath()
334 if wantCred == "" {
335 t.Skip("user credentials path unavailable")
336 }
337 if err := os.WriteFile(filepath.Join(project, ".env"), []byte("REASONIX_STATE_HOME="+redirect+"\nPLUGIN_TOKEN=project-token\n"), 0o600); err != nil {
338 t.Fatal(err)
339 }
340 if err := os.WriteFile(filepath.Join(project, ".mcp.json"), []byte(`{
341 "mcpServers": {
342 "svc": {
343 "type": "http",
344 "url": "https://mcp.example.test",
345 "headers": {
346 "Authorization": "Bearer ${PLUGIN_TOKEN}",
347 "State": "${REASONIX_STATE_HOME:-default-state}"
348 }
349 }
350 }
351 }`), 0o644); err != nil {
352 t.Fatal(err)
353 }
354
355 cfg, err := LoadForRoot(project)
356 if err != nil {
357 t.Fatalf("LoadForRoot: %v", err)
358 }
359 headers := cfg.Plugins[0].ExpandedPlugin().Headers
360 if got := headers["Authorization"]; got != "Bearer project-token" {
361 t.Fatalf("plugin token = %q, want project token", got)
362 }
363 if got := headers["State"]; got != "default-state" {
364 t.Fatalf("control var expansion = %q, want default-state", got)
365 }
366 if got := UserCredentialsPath(); got != wantCred {
367 t.Fatalf("project .env redirected credentials path: %q want %q", got, wantCred)
368 }
369 if got := os.Getenv("REASONIX_STATE_HOME"); got != "" {
370 t.Fatalf("project control var leaked into process env: %q", got)
371 }
372 }
373
374 func TestLoadForRootResolvesProviderCredentialsOverInheritedEnv(t *testing.T) {
375 project := t.TempDir()
376 cfgHome := t.TempDir()
377 key := "KEY_PROVIDER_GLOBAL_PRIORITY"
378
379 t.Setenv("HOME", cfgHome)
380 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
381 t.Setenv("USERPROFILE", cfgHome)
382 t.Setenv("XDG_CONFIG_HOME", filepath.Join(cfgHome, ".config"))
383 t.Setenv("AppData", filepath.Join(cfgHome, "AppData"))
384 t.Setenv(key, "from_env")
385
386 cred := UserCredentialsPath()
387 if cred == "" {
388 t.Skip("user config dir unresolved on this platform")
389 }
390 if err := os.MkdirAll(filepath.Dir(cred), 0o755); err != nil {
391 t.Fatal(err)
392 }
393 if err := os.WriteFile(cred, []byte(key+"=from_credentials\n"), 0o600); err != nil {
394 t.Fatal(err)
395 }
396 if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
397 default_model = "custom/m"
398 [[providers]]
399 name = "custom"
400 kind = "openai"
401 base_url = "https://example.invalid/v1"
402 model = "m"
403 api_key_env = "`+key+`"
404 `), 0o644); err != nil {
405 t.Fatal(err)
406 }
407
408 cfg, err := LoadForRoot(project)
409 if err != nil {
410 t.Fatalf("LoadForRoot: %v", err)
411 }
412 provider, ok := cfg.Provider("custom")
413 if !ok {
414 t.Fatalf("provider missing: %+v", cfg.Providers)
415 }
416 if got := provider.APIKey(); got != "from_credentials" {
417 t.Fatalf("provider API key = %q, want credentials value", got)
418 }
419 if got := os.Getenv(key); got != "from_credentials" {
420 t.Fatalf("process env = %q, want credentials value pinned over inherited env", got)
421 }
422 }
423
424 func TestLoadForRootIgnoresProjectProviderEnvAndInheritedEnv(t *testing.T) {
425 project := t.TempDir()
426 cfgHome := t.TempDir()
427 key := "KEY_PROVIDER_PROJECT_PRIORITY"
428
429 t.Setenv("HOME", cfgHome)
430 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
431 t.Setenv("USERPROFILE", cfgHome)
432 t.Setenv("XDG_CONFIG_HOME", filepath.Join(cfgHome, ".config"))
433 t.Setenv("AppData", filepath.Join(cfgHome, "AppData"))
434 t.Setenv(key, "from_env")
435
436 if err := os.WriteFile(filepath.Join(project, ".env"), []byte(key+"=from_project\n"), 0o600); err != nil {
437 t.Fatal(err)
438 }
439 if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
440 default_model = "custom/m"
441 [[providers]]
442 name = "custom"
443 kind = "openai"
444 base_url = "https://example.invalid/v1"
445 model = "m"
446 api_key_env = "`+key+`"
447 `), 0o644); err != nil {
448 t.Fatal(err)
449 }
450
451 cfg, err := LoadForRoot(project)
452 if err != nil {
453 t.Fatalf("LoadForRoot: %v", err)
454 }
455 provider, ok := cfg.Provider("custom")
456 if !ok {
457 t.Fatalf("provider missing: %+v", cfg.Providers)
458 }
459 if got := provider.APIKey(); got != "" {
460 t.Fatalf("provider API key = %q, want no key without global credentials", got)
461 }
462 if got := os.Getenv(key); got != "from_env" {
463 t.Fatalf("process env = %q, want inherited env left untouched", got)
464 }
465 }
466
467 func TestResolveCredentialGlobalFirstDoesNotMutateProjectEnv(t *testing.T) {
468 project := t.TempDir()
469 home := t.TempDir()
470
471 t.Setenv("HOME", home)
472 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
473 t.Setenv("USERPROFILE", home)
474 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
475 t.Setenv("AppData", filepath.Join(home, "AppData"))
476
477 key := "KEY_GLOBAL_PRIORITY"
478 if err := os.MkdirAll(filepath.Dir(UserCredentialsPath()), 0o755); err != nil {
479 t.Fatal(err)
480 }
481 if err := os.WriteFile(UserCredentialsPath(), []byte(key+"=from_credentials\n"), 0o600); err != nil {
482 t.Fatal(err)
483 }
484 if err := os.WriteFile(filepath.Join(project, ".env"), []byte(key+"=from_project\n"), 0o600); err != nil {
485 t.Fatal(err)
486 }
487 t.Setenv(key, "")
488 os.Unsetenv(key)
489 t.Setenv(key, "from_project")
490
491 if got := os.Getenv(key); got != "from_project" {
492 t.Fatalf("precondition: existing env should be project value, got %q", got)
493 }
494 got := ResolveCredentialForRootGlobalFirst(project, key)
495 if got.Value != "from_credentials" || got.Source.Kind != CredentialSourceCredentials {
496 t.Fatalf("credential = %+v, want global credentials for settings display", got)
497 }
498 if env := os.Getenv(key); env != "from_project" {
499 t.Fatalf("global-first resolution mutated process env: %q", env)
500 }
501 }
502
503 func TestCredentialResolverCachesGlobalFirstLookups(t *testing.T) {
504 project := t.TempDir()
505 key := "KEY_SETTINGS_CACHE"
506 calls := 0
507 stubStoredCredentialValueForTest(t, func(got string) (string, CredentialSource, bool) {
508 calls++
509 if got != key {
510 t.Fatalf("stored credential lookup key = %q, want %q", got, key)
511 }
512 return "from_credentials", CredentialSource{Kind: CredentialSourceCredentials, Label: "Reasonix credentials"}, true
513 })
514
515 resolver := NewCredentialResolverForRoot(project)
516 first := resolver.ResolveGlobalFirst(key)
517 second := resolver.ResolveGlobalFirst(key)
518
519 if !first.Set || first.Value != "from_credentials" {
520 t.Fatalf("first credential = %+v, want stored credential", first)
521 }
522 if !second.Set || second.Value != "from_credentials" {
523 t.Fatalf("second credential = %+v, want cached stored credential", second)
524 }
525 if calls != 1 {
526 t.Fatalf("stored credential lookups = %d, want 1 for repeated key in one resolver", calls)
527 }
528 }
529
530 func stubStoredCredentialValueForTest(t *testing.T, fn func(string) (string, CredentialSource, bool)) {
531 t.Helper()
532 old := storedCredentialValueLookup
533 storedCredentialValueLookup = fn
534 t.Cleanup(func() {
535 storedCredentialValueLookup = old
536 })
537 }
538
539 func TestResolveCredentialSourceShowsCredentialsShadowingProjectEnv(t *testing.T) {
540 cwd := t.TempDir()
541 cfgHome := t.TempDir()
542
543 t.Chdir(cwd)
544 t.Setenv("HOME", cfgHome)
545 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
546 t.Setenv("USERPROFILE", cfgHome)
547 t.Setenv("XDG_CONFIG_HOME", filepath.Join(cfgHome, ".config"))
548 t.Setenv("AppData", filepath.Join(cfgHome, "AppData"))
549
550 key := "KEY_SOURCE_PROJECT"
551 cred := UserCredentialsPath()
552 if cred == "" {
553 t.Skip("user config dir unresolved on this platform")
554 }
555 if err := os.MkdirAll(filepath.Dir(cred), 0o755); err != nil {
556 t.Fatal(err)
557 }
558 if err := os.WriteFile(cred, []byte(key+"=from_credentials\n"), 0o600); err != nil {
559 t.Fatal(err)
560 }
561 if err := os.WriteFile(filepath.Join(cwd, ".env"), []byte(key+"=from_project\n"), 0o600); err != nil {
562 t.Fatal(err)
563 }
564 t.Setenv(key, "")
565 os.Unsetenv(key)
566
567 loadDotEnv()
568
569 got := ResolveCredentialForRoot(cwd, key)
570 if !got.Set || got.Source.Kind != CredentialSourceCredentials {
571 t.Fatalf("source = %+v set=%v, want Reasonix credentials", got.Source, got.Set)
572 }
573 foundProjectShadow := false
574 for _, source := range got.Shadowed {
575 if source.Kind == CredentialSourceProjectEnv {
576 foundProjectShadow = true
577 }
578 }
579 if !foundProjectShadow {
580 t.Fatalf("shadowed = %+v, want project .env shadowed by credentials", got.Shadowed)
581 }
582 }
583
584 func TestResolveCredentialSourceShowsCredentialsShadowingEmptyProjectEnv(t *testing.T) {
585 cwd := t.TempDir()
586 cfgHome := t.TempDir()
587
588 t.Chdir(cwd)
589 t.Setenv("HOME", cfgHome)
590 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
591 t.Setenv("USERPROFILE", cfgHome)
592 t.Setenv("XDG_CONFIG_HOME", filepath.Join(cfgHome, ".config"))
593 t.Setenv("AppData", filepath.Join(cfgHome, "AppData"))
594
595 key := "KEY_SOURCE_EMPTY_PROJECT"
596 cred := UserCredentialsPath()
597 if cred == "" {
598 t.Skip("user config dir unresolved on this platform")
599 }
600 if err := os.MkdirAll(filepath.Dir(cred), 0o755); err != nil {
601 t.Fatal(err)
602 }
603 if err := os.WriteFile(cred, []byte(key+"=from_credentials\n"), 0o600); err != nil {
604 t.Fatal(err)
605 }
606 if err := os.WriteFile(filepath.Join(cwd, ".env"), []byte(key+"=\n"), 0o600); err != nil {
607 t.Fatal(err)
608 }
609 t.Setenv(key, "")
610 os.Unsetenv(key)
611
612 if _, err := StoreCredentialLines([]string{key + "=from_credentials"}); err != nil {
613 t.Fatalf("StoreCredentialLines: %v", err)
614 }
615
616 got := ResolveCredentialForRoot(cwd, key)
617 foundProjectShadow := false
618 for _, source := range got.Shadowed {
619 if source.Kind == CredentialSourceProjectEnv {
620 foundProjectShadow = true
621 }
622 }
623 if !foundProjectShadow {
624 t.Fatalf("shadowed = %+v, want empty project .env shadowed by credentials", got.Shadowed)
625 }
626 }
627
628 func TestResolveCredentialSourceShowsCredentialsBeforeHomeEnv(t *testing.T) {
629 cwd := t.TempDir()
630 cfgHome := t.TempDir()
631
632 t.Chdir(cwd)
633 t.Setenv("HOME", cfgHome)
634 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
635 t.Setenv("USERPROFILE", cfgHome)
636 t.Setenv("XDG_CONFIG_HOME", filepath.Join(cfgHome, ".config"))
637 t.Setenv("AppData", filepath.Join(cfgHome, "AppData"))
638
639 key := "KEY_SOURCE_CREDENTIALS"
640 cred := UserCredentialsPath()
641 if cred == "" {
642 t.Skip("user config dir unresolved on this platform")
643 }
644 if err := os.MkdirAll(filepath.Dir(cred), 0o755); err != nil {
645 t.Fatal(err)
646 }
647 if err := os.WriteFile(cred, []byte(key+"=from_credentials\n"), 0o600); err != nil {
648 t.Fatal(err)
649 }
650 if err := os.WriteFile(filepath.Join(cfgHome, ".env"), []byte(key+"=from_home\n"), 0o600); err != nil {
651 t.Fatal(err)
652 }
653 t.Setenv(key, "")
654 os.Unsetenv(key)
655
656 loadDotEnv()
657
658 got := ResolveCredentialForRoot(cwd, key)
659 if !got.Set || got.Source.Kind != CredentialSourceCredentials {
660 t.Fatalf("source = %+v set=%v, want Reasonix credentials", got.Source, got.Set)
661 }
662 if got.Value != "from_credentials" {
663 t.Fatalf("value = %q, want credentials value", got.Value)
664 }
665 }
666
667 func TestStoreCredentialLinesFileMode(t *testing.T) {
668 home := t.TempDir()
669 t.Setenv("HOME", home)
670 t.Setenv("USERPROFILE", home)
671 t.Setenv("AppData", filepath.Join(home, "AppData"))
672 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
673 t.Setenv("KEY_FILE_MODE", "")
674 os.Unsetenv("KEY_FILE_MODE")
675
676 target, err := StoreCredentialLines([]string{"KEY_FILE_MODE=from_file_store"})
677 if err != nil {
678 t.Fatalf("StoreCredentialLines: %v", err)
679 }
680 if target != UserCredentialsPath() {
681 t.Fatalf("target = %q, want %q", target, UserCredentialsPath())
682 }
683 data, err := os.ReadFile(UserCredentialsPath())
684 if err != nil {
685 t.Fatalf("read credentials file: %v", err)
686 }
687 if string(data) != "KEY_FILE_MODE=from_file_store\n" {
688 t.Fatalf("credentials file = %q", data)
689 }
690 if got := os.Getenv("KEY_FILE_MODE"); got != "from_file_store" {
691 t.Fatalf("process env = %q, want stored value", got)
692 }
693 }
694
695 func TestUserCredentialsPathIgnoresReasonixStateHome(t *testing.T) {
696 home := t.TempDir()
697 state := t.TempDir()
698 t.Setenv("HOME", home)
699 t.Setenv("USERPROFILE", home)
700 t.Setenv("AppData", filepath.Join(home, "AppData"))
701 t.Setenv("REASONIX_HOME", filepath.Join(home, "reasonix-home"))
702 t.Setenv("REASONIX_STATE_HOME", state)
703
704 want := filepath.Join(home, "reasonix-home", ".env")
705 if got := UserCredentialsPath(); got != want {
706 t.Fatalf("UserCredentialsPath() = %q, want %q", got, want)
707 }
708 if strings.HasPrefix(UserCredentialsPath(), state) {
709 t.Fatalf("credentials path must not live under REASONIX_STATE_HOME: %q", UserCredentialsPath())
710 }
711 }
712
713 func TestRemoveCredentialMarksClearedAndSetRemovesMarker(t *testing.T) {
714 home := t.TempDir()
715 t.Setenv("HOME", home)
716 t.Setenv("USERPROFILE", home)
717 t.Setenv("AppData", filepath.Join(home, "AppData"))
718 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
719
720 if _, err := SetCredential("KEY_REMOVE_MARKER", "old"); err != nil {
721 t.Fatalf("SetCredential old: %v", err)
722 }
723 if err := RemoveCredential("KEY_REMOVE_MARKER"); err != nil {
724 t.Fatalf("RemoveCredential: %v", err)
725 }
726 if CredentialStored("KEY_REMOVE_MARKER") {
727 t.Fatal("removed key should not be stored")
728 }
729 if !credentialCurrentStoreClearedKey("KEY_REMOVE_MARKER") {
730 t.Fatal("removed key should leave a cleared marker")
731 }
732 if _, err := SetCredential("KEY_REMOVE_MARKER", "new"); err != nil {
733 t.Fatalf("SetCredential new: %v", err)
734 }
735 data, err := os.ReadFile(UserCredentialsPath())
736 if err != nil {
737 t.Fatalf("read credentials: %v", err)
738 }
739 if got := string(data); got != "KEY_REMOVE_MARKER=new\n" {
740 t.Fatalf("credentials after re-set = %q", got)
741 }
742 }
743
744 func TestCredentialStoreRevisionChangesForSameLengthReplacement(t *testing.T) {
745 home := t.TempDir()
746 t.Setenv("HOME", home)
747 t.Setenv("USERPROFILE", home)
748 t.Setenv("AppData", filepath.Join(home, "AppData"))
749 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
750
751 if got := CredentialStoreRevision(); got != "missing" {
752 t.Fatalf("initial credential revision = %q, want missing", got)
753 }
754 if _, err := SetCredential("REVISION_KEY", "old-key"); err != nil {
755 t.Fatalf("SetCredential(old): %v", err)
756 }
757 oldRevision := CredentialStoreRevision()
758 if _, err := SetCredential("REVISION_KEY", "new-key"); err != nil {
759 t.Fatalf("SetCredential(new): %v", err)
760 }
761 newRevision := CredentialStoreRevision()
762 if oldRevision == newRevision {
763 t.Fatalf("same-length replacement kept credential revision %q", oldRevision)
764 }
765 }
766
767 func TestSetCredentialIfRevisionRejectsStaleWriter(t *testing.T) {
768 home := t.TempDir()
769 t.Setenv("HOME", home)
770 t.Setenv("USERPROFILE", home)
771 t.Setenv("AppData", filepath.Join(home, "AppData"))
772 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
773
774 missingRevision := CredentialStoreRevision()
775 if _, err := SetCredential("REVISION_CAS_KEY", "newer-key"); err != nil {
776 t.Fatalf("SetCredential(newer): %v", err)
777 }
778 if _, applied, err := SetCredentialIfRevision("REVISION_CAS_KEY", "stale-key", missingRevision); err != nil {
779 t.Fatalf("SetCredentialIfRevision(stale): %v", err)
780 } else if applied {
781 t.Fatal("stale credential revision overwrote a newer value")
782 }
783 resolved := ResolveCredentialForRootGlobalFirst(".", "REVISION_CAS_KEY")
784 if !resolved.Set || resolved.Value != "newer-key" {
785 t.Fatalf("credential after stale write = set:%v value:%q, want newer-key", resolved.Set, resolved.Value)
786 }
787 }
788
789 func TestSetCredentialIfRevisionAppliesCurrentWriter(t *testing.T) {
790 home := t.TempDir()
791 t.Setenv("HOME", home)
792 t.Setenv("USERPROFILE", home)
793 t.Setenv("AppData", filepath.Join(home, "AppData"))
794 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
795
796 revision := CredentialStoreRevision()
797 if _, applied, err := SetCredentialIfRevision("REVISION_CAS_KEY", "accepted-key", revision); err != nil {
798 t.Fatalf("SetCredentialIfRevision(current): %v", err)
799 } else if !applied {
800 t.Fatal("current credential revision was rejected")
801 }
802 resolved := ResolveCredentialForRootGlobalFirst(".", "REVISION_CAS_KEY")
803 if !resolved.Set || resolved.Value != "accepted-key" {
804 t.Fatalf("stored credential = set:%v value:%q, want accepted-key", resolved.Set, resolved.Value)
805 }
806 }
807
808 func TestStoreCredentialLinesRejectsUnsafeFileLines(t *testing.T) {
809 home := t.TempDir()
810 t.Setenv("HOME", home)
811 t.Setenv("USERPROFILE", home)
812 t.Setenv("AppData", filepath.Join(home, "AppData"))
813 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
814
815 _, err := StoreCredentialLines([]string{
816 "VALID_KEY=kept",
817 "BAD-KEY=ignored",
818 "MULTILINE=first\nINJECTED=second",
819 })
820 if err != nil {
821 t.Fatalf("StoreCredentialLines: %v", err)
822 }
823 data, err := os.ReadFile(UserCredentialsPath())
824 if err != nil {
825 t.Fatalf("read credentials file: %v", err)
826 }
827 if string(data) != "VALID_KEY=kept\n" {
828 t.Fatalf("credentials file = %q", data)
829 }
830 if got := os.Getenv("INJECTED"); got != "" {
831 t.Fatalf("injected env was set: %q", got)
832 }
833 }
834
835 func TestStoreCredentialLinesParsesDotenvQuotes(t *testing.T) {
836 home := t.TempDir()
837 t.Setenv("HOME", home)
838 t.Setenv("USERPROFILE", home)
839 t.Setenv("AppData", filepath.Join(home, "AppData"))
840 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
841
842 _, err := StoreCredentialLines([]string{`export QUOTED_KEY="value with spaces # kept"`})
843 if err != nil {
844 t.Fatalf("StoreCredentialLines: %v", err)
845 }
846 data, err := os.ReadFile(UserCredentialsPath())
847 if err != nil {
848 t.Fatalf("read credentials file: %v", err)
849 }
850 if string(data) != "QUOTED_KEY=\"value with spaces # kept\"\n" {
851 t.Fatalf("credentials file = %q", data)
852 }
853 if got := os.Getenv("QUOTED_KEY"); got != "value with spaces # kept" {
854 t.Fatalf("env QUOTED_KEY = %q", got)
855 }
856 }
857
858 func TestSetCredentialRejectsInvalidInput(t *testing.T) {
859 home := t.TempDir()
860 t.Setenv("HOME", home)
861 t.Setenv("USERPROFILE", home)
862 t.Setenv("AppData", filepath.Join(home, "AppData"))
863 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
864
865 if _, err := SetCredential("BAD-KEY", "value"); err == nil {
866 t.Fatal("SetCredential accepted invalid key")
867 }
868 if _, err := SetCredential("VALID_KEY", "first\nsecond"); err == nil {
869 t.Fatal("SetCredential accepted newline value")
870 }
871 if _, err := os.Stat(UserCredentialsPath()); !os.IsNotExist(err) {
872 t.Fatalf("credentials file should not be created for rejected input, stat err=%v", err)
873 }
874 }
875
876 func TestProjectConfigCannotOverrideCredentialStoreMode(t *testing.T) {
877 home := isolateUserConfigHome(t)
878 project := t.TempDir()
879 t.Setenv("REASONIX_CREDENTIALS_STORE", "")
880 os.Unsetenv("REASONIX_CREDENTIALS_STORE")
881 requireTestPathWithin(t, home, UserConfigPath())
882 if err := os.MkdirAll(filepath.Dir(UserConfigPath()), 0o755); err != nil {
883 t.Fatal(err)
884 }
885 if err := os.WriteFile(UserConfigPath(), []byte(`credentials_store = "file"`), 0o644); err != nil {
886 t.Fatal(err)
887 }
888 if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`credentials_store = "keyring"`), 0o644); err != nil {
889 t.Fatal(err)
890 }
891
892 cfg, err := LoadForRoot(project)
893 if err != nil {
894 t.Fatalf("LoadForRoot: %v", err)
895 }
896 if cfg.CredentialsStore != CredentialsStoreFile {
897 t.Fatalf("CredentialsStore = %q, want file from user config", cfg.CredentialsStore)
898 }
899 }
900
901 // TestLoadDotEnvGlobalCredentialsOverrideEnv confirms Reasonix-owned global
902 // credentials beat inherited environment variables.
903 func TestLoadDotEnvGlobalCredentialsOverrideEnv(t *testing.T) {
904 home := t.TempDir()
905 t.Setenv("HOME", home)
906 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
907 t.Setenv("USERPROFILE", home)
908 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
909 t.Setenv("AppData", filepath.Join(home, "AppData"))
910 t.Setenv("PINNED", "from_env")
911 if err := os.MkdirAll(filepath.Dir(UserCredentialsPath()), 0o755); err != nil {
912 t.Fatal(err)
913 }
914 if err := os.WriteFile(UserCredentialsPath(), []byte("PINNED=from_credentials\n"), 0o600); err != nil {
915 t.Fatal(err)
916 }
917
918 loadDotEnv()
919
920 if got := os.Getenv("PINNED"); got != "from_credentials" {
921 t.Errorf("global credentials must win over inherited env: PINNED=%q want from_credentials", got)
922 }
923 }
924
924 lines GO