返回 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.MustEncode("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 TestResolveCredentialGlobalFirstDoesNotMutateProjectEnv(t *testing.T) {
375 project := t.TempDir()
376 home := t.TempDir()
377
378 t.Setenv("HOME", home)
379 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
380 t.Setenv("USERPROFILE", home)
381 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
382 t.Setenv("AppData", filepath.Join(home, "AppData"))
383
384 key := "KEY_GLOBAL_PRIORITY"
385 if err := os.MkdirAll(filepath.Dir(UserCredentialsPath()), 0o755); err != nil {
386 t.Fatal(err)
387 }
388 if err := os.WriteFile(UserCredentialsPath(), []byte(key+"=from_credentials\n"), 0o600); err != nil {
389 t.Fatal(err)
390 }
391 if err := os.WriteFile(filepath.Join(project, ".env"), []byte(key+"=from_project\n"), 0o600); err != nil {
392 t.Fatal(err)
393 }
394 t.Setenv(key, "")
395 os.Unsetenv(key)
396 t.Setenv(key, "from_project")
397
398 if got := os.Getenv(key); got != "from_project" {
399 t.Fatalf("precondition: existing env should be project value, got %q", got)
400 }
401 got := ResolveCredentialForRootGlobalFirst(project, key)
402 if got.Value != "from_credentials" || got.Source.Kind != CredentialSourceCredentials {
403 t.Fatalf("credential = %+v, want global credentials for settings display", got)
404 }
405 if env := os.Getenv(key); env != "from_project" {
406 t.Fatalf("global-first resolution mutated process env: %q", env)
407 }
408 }
409
410 func TestCredentialResolverCachesGlobalFirstLookups(t *testing.T) {
411 project := t.TempDir()
412 key := "KEY_SETTINGS_CACHE"
413 calls := 0
414 stubStoredCredentialValueForTest(t, func(got string) (string, CredentialSource, bool) {
415 calls++
416 if got != key {
417 t.Fatalf("stored credential lookup key = %q, want %q", got, key)
418 }
419 return "from_credentials", CredentialSource{Kind: CredentialSourceCredentials, Label: "Reasonix credentials"}, true
420 })
421
422 resolver := NewCredentialResolverForRoot(project)
423 first := resolver.ResolveGlobalFirst(key)
424 second := resolver.ResolveGlobalFirst(key)
425
426 if !first.Set || first.Value != "from_credentials" {
427 t.Fatalf("first credential = %+v, want stored credential", first)
428 }
429 if !second.Set || second.Value != "from_credentials" {
430 t.Fatalf("second credential = %+v, want cached stored credential", second)
431 }
432 if calls != 1 {
433 t.Fatalf("stored credential lookups = %d, want 1 for repeated key in one resolver", calls)
434 }
435 }
436
437 func stubStoredCredentialValueForTest(t *testing.T, fn func(string) (string, CredentialSource, bool)) {
438 t.Helper()
439 old := storedCredentialValueLookup
440 storedCredentialValueLookup = fn
441 t.Cleanup(func() {
442 storedCredentialValueLookup = old
443 })
444 }
445
446 func TestResolveCredentialSourceShowsCredentialsShadowingProjectEnv(t *testing.T) {
447 cwd := t.TempDir()
448 cfgHome := t.TempDir()
449
450 t.Chdir(cwd)
451 t.Setenv("HOME", cfgHome)
452 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
453 t.Setenv("USERPROFILE", cfgHome)
454 t.Setenv("XDG_CONFIG_HOME", filepath.Join(cfgHome, ".config"))
455 t.Setenv("AppData", filepath.Join(cfgHome, "AppData"))
456
457 key := "KEY_SOURCE_PROJECT"
458 cred := UserCredentialsPath()
459 if cred == "" {
460 t.Skip("user config dir unresolved on this platform")
461 }
462 if err := os.MkdirAll(filepath.Dir(cred), 0o755); err != nil {
463 t.Fatal(err)
464 }
465 if err := os.WriteFile(cred, []byte(key+"=from_credentials\n"), 0o600); err != nil {
466 t.Fatal(err)
467 }
468 if err := os.WriteFile(filepath.Join(cwd, ".env"), []byte(key+"=from_project\n"), 0o600); err != nil {
469 t.Fatal(err)
470 }
471 t.Setenv(key, "")
472 os.Unsetenv(key)
473
474 loadDotEnv()
475
476 got := ResolveCredentialForRoot(cwd, key)
477 if !got.Set || got.Source.Kind != CredentialSourceCredentials {
478 t.Fatalf("source = %+v set=%v, want Reasonix credentials", got.Source, got.Set)
479 }
480 foundProjectShadow := false
481 for _, source := range got.Shadowed {
482 if source.Kind == CredentialSourceProjectEnv {
483 foundProjectShadow = true
484 }
485 }
486 if !foundProjectShadow {
487 t.Fatalf("shadowed = %+v, want project .env shadowed by credentials", got.Shadowed)
488 }
489 }
490
491 func TestResolveCredentialSourceShowsCredentialsShadowingEmptyProjectEnv(t *testing.T) {
492 cwd := t.TempDir()
493 cfgHome := t.TempDir()
494
495 t.Chdir(cwd)
496 t.Setenv("HOME", cfgHome)
497 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
498 t.Setenv("USERPROFILE", cfgHome)
499 t.Setenv("XDG_CONFIG_HOME", filepath.Join(cfgHome, ".config"))
500 t.Setenv("AppData", filepath.Join(cfgHome, "AppData"))
501
502 key := "KEY_SOURCE_EMPTY_PROJECT"
503 cred := UserCredentialsPath()
504 if cred == "" {
505 t.Skip("user config dir unresolved on this platform")
506 }
507 if err := os.MkdirAll(filepath.Dir(cred), 0o755); err != nil {
508 t.Fatal(err)
509 }
510 if err := os.WriteFile(cred, []byte(key+"=from_credentials\n"), 0o600); err != nil {
511 t.Fatal(err)
512 }
513 if err := os.WriteFile(filepath.Join(cwd, ".env"), []byte(key+"=\n"), 0o600); err != nil {
514 t.Fatal(err)
515 }
516 t.Setenv(key, "")
517 os.Unsetenv(key)
518
519 if _, err := StoreCredentialLines([]string{key + "=from_credentials"}); err != nil {
520 t.Fatalf("StoreCredentialLines: %v", err)
521 }
522
523 got := ResolveCredentialForRoot(cwd, key)
524 foundProjectShadow := false
525 for _, source := range got.Shadowed {
526 if source.Kind == CredentialSourceProjectEnv {
527 foundProjectShadow = true
528 }
529 }
530 if !foundProjectShadow {
531 t.Fatalf("shadowed = %+v, want empty project .env shadowed by credentials", got.Shadowed)
532 }
533 }
534
535 func TestResolveCredentialSourceShowsCredentialsBeforeHomeEnv(t *testing.T) {
536 cwd := t.TempDir()
537 cfgHome := t.TempDir()
538
539 t.Chdir(cwd)
540 t.Setenv("HOME", cfgHome)
541 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
542 t.Setenv("USERPROFILE", cfgHome)
543 t.Setenv("XDG_CONFIG_HOME", filepath.Join(cfgHome, ".config"))
544 t.Setenv("AppData", filepath.Join(cfgHome, "AppData"))
545
546 key := "KEY_SOURCE_CREDENTIALS"
547 cred := UserCredentialsPath()
548 if cred == "" {
549 t.Skip("user config dir unresolved on this platform")
550 }
551 if err := os.MkdirAll(filepath.Dir(cred), 0o755); err != nil {
552 t.Fatal(err)
553 }
554 if err := os.WriteFile(cred, []byte(key+"=from_credentials\n"), 0o600); err != nil {
555 t.Fatal(err)
556 }
557 if err := os.WriteFile(filepath.Join(cfgHome, ".env"), []byte(key+"=from_home\n"), 0o600); err != nil {
558 t.Fatal(err)
559 }
560 t.Setenv(key, "")
561 os.Unsetenv(key)
562
563 loadDotEnv()
564
565 got := ResolveCredentialForRoot(cwd, key)
566 if !got.Set || got.Source.Kind != CredentialSourceCredentials {
567 t.Fatalf("source = %+v set=%v, want Reasonix credentials", got.Source, got.Set)
568 }
569 if got.Value != "from_credentials" {
570 t.Fatalf("value = %q, want credentials value", got.Value)
571 }
572 }
573
574 func TestStoreCredentialLinesFileMode(t *testing.T) {
575 home := t.TempDir()
576 t.Setenv("HOME", home)
577 t.Setenv("USERPROFILE", home)
578 t.Setenv("AppData", filepath.Join(home, "AppData"))
579 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
580 t.Setenv("KEY_FILE_MODE", "")
581 os.Unsetenv("KEY_FILE_MODE")
582
583 target, err := StoreCredentialLines([]string{"KEY_FILE_MODE=from_file_store"})
584 if err != nil {
585 t.Fatalf("StoreCredentialLines: %v", err)
586 }
587 if target != UserCredentialsPath() {
588 t.Fatalf("target = %q, want %q", target, UserCredentialsPath())
589 }
590 data, err := os.ReadFile(UserCredentialsPath())
591 if err != nil {
592 t.Fatalf("read credentials file: %v", err)
593 }
594 if string(data) != "KEY_FILE_MODE=from_file_store\n" {
595 t.Fatalf("credentials file = %q", data)
596 }
597 if got := os.Getenv("KEY_FILE_MODE"); got != "from_file_store" {
598 t.Fatalf("process env = %q, want stored value", got)
599 }
600 }
601
602 func TestUserCredentialsPathIgnoresReasonixStateHome(t *testing.T) {
603 home := t.TempDir()
604 state := t.TempDir()
605 t.Setenv("HOME", home)
606 t.Setenv("USERPROFILE", home)
607 t.Setenv("AppData", filepath.Join(home, "AppData"))
608 t.Setenv("REASONIX_HOME", filepath.Join(home, "reasonix-home"))
609 t.Setenv("REASONIX_STATE_HOME", state)
610
611 want := filepath.Join(home, "reasonix-home", ".env")
612 if got := UserCredentialsPath(); got != want {
613 t.Fatalf("UserCredentialsPath() = %q, want %q", got, want)
614 }
615 if strings.HasPrefix(UserCredentialsPath(), state) {
616 t.Fatalf("credentials path must not live under REASONIX_STATE_HOME: %q", UserCredentialsPath())
617 }
618 }
619
620 func TestRemoveCredentialMarksClearedAndSetRemovesMarker(t *testing.T) {
621 home := t.TempDir()
622 t.Setenv("HOME", home)
623 t.Setenv("USERPROFILE", home)
624 t.Setenv("AppData", filepath.Join(home, "AppData"))
625 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
626
627 if _, err := SetCredential("KEY_REMOVE_MARKER", "old"); err != nil {
628 t.Fatalf("SetCredential old: %v", err)
629 }
630 if err := RemoveCredential("KEY_REMOVE_MARKER"); err != nil {
631 t.Fatalf("RemoveCredential: %v", err)
632 }
633 if CredentialStored("KEY_REMOVE_MARKER") {
634 t.Fatal("removed key should not be stored")
635 }
636 if !credentialCurrentStoreClearedKey("KEY_REMOVE_MARKER") {
637 t.Fatal("removed key should leave a cleared marker")
638 }
639 if _, err := SetCredential("KEY_REMOVE_MARKER", "new"); err != nil {
640 t.Fatalf("SetCredential new: %v", err)
641 }
642 data, err := os.ReadFile(UserCredentialsPath())
643 if err != nil {
644 t.Fatalf("read credentials: %v", err)
645 }
646 if got := string(data); got != "KEY_REMOVE_MARKER=new\n" {
647 t.Fatalf("credentials after re-set = %q", got)
648 }
649 }
650
651 func TestCredentialStoreRevisionChangesForSameLengthReplacement(t *testing.T) {
652 home := t.TempDir()
653 t.Setenv("HOME", home)
654 t.Setenv("USERPROFILE", home)
655 t.Setenv("AppData", filepath.Join(home, "AppData"))
656 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
657
658 if got := CredentialStoreRevision(); got != "missing" {
659 t.Fatalf("initial credential revision = %q, want missing", got)
660 }
661 if _, err := SetCredential("REVISION_KEY", "old-key"); err != nil {
662 t.Fatalf("SetCredential(old): %v", err)
663 }
664 oldRevision := CredentialStoreRevision()
665 if _, err := SetCredential("REVISION_KEY", "new-key"); err != nil {
666 t.Fatalf("SetCredential(new): %v", err)
667 }
668 newRevision := CredentialStoreRevision()
669 if oldRevision == newRevision {
670 t.Fatalf("same-length replacement kept credential revision %q", oldRevision)
671 }
672 }
673
674 func TestSetCredentialIfRevisionRejectsStaleWriter(t *testing.T) {
675 home := t.TempDir()
676 t.Setenv("HOME", home)
677 t.Setenv("USERPROFILE", home)
678 t.Setenv("AppData", filepath.Join(home, "AppData"))
679 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
680
681 missingRevision := CredentialStoreRevision()
682 if _, err := SetCredential("REVISION_CAS_KEY", "newer-key"); err != nil {
683 t.Fatalf("SetCredential(newer): %v", err)
684 }
685 if _, applied, err := SetCredentialIfRevision("REVISION_CAS_KEY", "stale-key", missingRevision); err != nil {
686 t.Fatalf("SetCredentialIfRevision(stale): %v", err)
687 } else if applied {
688 t.Fatal("stale credential revision overwrote a newer value")
689 }
690 resolved := ResolveCredentialForRootGlobalFirst(".", "REVISION_CAS_KEY")
691 if !resolved.Set || resolved.Value != "newer-key" {
692 t.Fatalf("credential after stale write = set:%v value:%q, want newer-key", resolved.Set, resolved.Value)
693 }
694 }
695
696 func TestSetCredentialIfRevisionAppliesCurrentWriter(t *testing.T) {
697 home := t.TempDir()
698 t.Setenv("HOME", home)
699 t.Setenv("USERPROFILE", home)
700 t.Setenv("AppData", filepath.Join(home, "AppData"))
701 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
702
703 revision := CredentialStoreRevision()
704 if _, applied, err := SetCredentialIfRevision("REVISION_CAS_KEY", "accepted-key", revision); err != nil {
705 t.Fatalf("SetCredentialIfRevision(current): %v", err)
706 } else if !applied {
707 t.Fatal("current credential revision was rejected")
708 }
709 resolved := ResolveCredentialForRootGlobalFirst(".", "REVISION_CAS_KEY")
710 if !resolved.Set || resolved.Value != "accepted-key" {
711 t.Fatalf("stored credential = set:%v value:%q, want accepted-key", resolved.Set, resolved.Value)
712 }
713 }
714
715 func TestStoreCredentialLinesRejectsUnsafeFileLines(t *testing.T) {
716 home := t.TempDir()
717 t.Setenv("HOME", home)
718 t.Setenv("USERPROFILE", home)
719 t.Setenv("AppData", filepath.Join(home, "AppData"))
720 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
721
722 _, err := StoreCredentialLines([]string{
723 "VALID_KEY=kept",
724 "BAD-KEY=ignored",
725 "MULTILINE=first\nINJECTED=second",
726 })
727 if err != nil {
728 t.Fatalf("StoreCredentialLines: %v", err)
729 }
730 data, err := os.ReadFile(UserCredentialsPath())
731 if err != nil {
732 t.Fatalf("read credentials file: %v", err)
733 }
734 if string(data) != "VALID_KEY=kept\n" {
735 t.Fatalf("credentials file = %q", data)
736 }
737 if got := os.Getenv("INJECTED"); got != "" {
738 t.Fatalf("injected env was set: %q", got)
739 }
740 }
741
742 func TestStoreCredentialLinesParsesDotenvQuotes(t *testing.T) {
743 home := t.TempDir()
744 t.Setenv("HOME", home)
745 t.Setenv("USERPROFILE", home)
746 t.Setenv("AppData", filepath.Join(home, "AppData"))
747 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
748
749 _, err := StoreCredentialLines([]string{`export QUOTED_KEY="value with spaces # kept"`})
750 if err != nil {
751 t.Fatalf("StoreCredentialLines: %v", err)
752 }
753 data, err := os.ReadFile(UserCredentialsPath())
754 if err != nil {
755 t.Fatalf("read credentials file: %v", err)
756 }
757 if string(data) != "QUOTED_KEY=\"value with spaces # kept\"\n" {
758 t.Fatalf("credentials file = %q", data)
759 }
760 if got := os.Getenv("QUOTED_KEY"); got != "value with spaces # kept" {
761 t.Fatalf("env QUOTED_KEY = %q", got)
762 }
763 }
764
765 func TestSetCredentialRejectsInvalidInput(t *testing.T) {
766 home := t.TempDir()
767 t.Setenv("HOME", home)
768 t.Setenv("USERPROFILE", home)
769 t.Setenv("AppData", filepath.Join(home, "AppData"))
770 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
771
772 if _, err := SetCredential("BAD-KEY", "value"); err == nil {
773 t.Fatal("SetCredential accepted invalid key")
774 }
775 if _, err := SetCredential("VALID_KEY", "first\nsecond"); err == nil {
776 t.Fatal("SetCredential accepted newline value")
777 }
778 if _, err := os.Stat(UserCredentialsPath()); !os.IsNotExist(err) {
779 t.Fatalf("credentials file should not be created for rejected input, stat err=%v", err)
780 }
781 }
782
783 func TestProjectConfigCannotOverrideCredentialStoreMode(t *testing.T) {
784 home := isolateUserConfigHome(t)
785 project := t.TempDir()
786 t.Setenv("REASONIX_CREDENTIALS_STORE", "")
787 os.Unsetenv("REASONIX_CREDENTIALS_STORE")
788 requireTestPathWithin(t, home, UserConfigPath())
789 if err := os.MkdirAll(filepath.Dir(UserConfigPath()), 0o755); err != nil {
790 t.Fatal(err)
791 }
792 if err := os.WriteFile(UserConfigPath(), []byte(`credentials_store = "file"`), 0o644); err != nil {
793 t.Fatal(err)
794 }
795 if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`credentials_store = "keyring"`), 0o644); err != nil {
796 t.Fatal(err)
797 }
798 approveWorkspace(t, project)
799
800 cfg, err := LoadForRoot(project)
801 if err != nil {
802 t.Fatalf("LoadForRoot: %v", err)
803 }
804 if cfg.CredentialsStore != CredentialsStoreFile {
805 t.Fatalf("CredentialsStore = %q, want file from user config", cfg.CredentialsStore)
806 }
807 }
808
809 // TestLoadDotEnvGlobalCredentialsOverrideEnv confirms Reasonix-owned global
810 // credentials beat inherited environment variables.
811 func TestLoadDotEnvGlobalCredentialsOverrideEnv(t *testing.T) {
812 home := t.TempDir()
813 t.Setenv("HOME", home)
814 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
815 t.Setenv("USERPROFILE", home)
816 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
817 t.Setenv("AppData", filepath.Join(home, "AppData"))
818 t.Setenv("PINNED", "from_env")
819 if err := os.MkdirAll(filepath.Dir(UserCredentialsPath()), 0o755); err != nil {
820 t.Fatal(err)
821 }
822 if err := os.WriteFile(UserCredentialsPath(), []byte("PINNED=from_credentials\n"), 0o600); err != nil {
823 t.Fatal(err)
824 }
825
826 loadDotEnv()
827
828 if got := os.Getenv("PINNED"); got != "from_credentials" {
829 t.Errorf("global credentials must win over inherited env: PINNED=%q want from_credentials", got)
830 }
831 }
832
832 lines GO