返回 DeepSeek-Reasonix
credential_rotation_test.go
根目录 / internal / config / credential_rotation_test.go
1 package config
2
3 import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8 )
9
10 func rotationFixture(t *testing.T, providers ...ProviderEntry) string {
11 t.Helper()
12 isolateUserConfigHome(t)
13 unsetForTest(t, "TEAM_KEY")
14 path := UserConfigPath()
15 cfg := Default()
16 cfg.Providers = append(cfg.Providers, providers...)
17 if err := cfg.SaveTo(path); err != nil {
18 t.Fatal(err)
19 }
20 if _, err := SetCredential("TEAM_KEY", "sk-old"); err != nil {
21 t.Fatal(err)
22 }
23 return path
24 }
25
26 func teamProvider(name string) ProviderEntry {
27 return ProviderEntry{Name: name, Kind: "openai", BaseURL: "https://" + name + ".invalid/v1", Model: "chat", APIKeyEnv: "TEAM_KEY"}
28 }
29
30 func withEditLocks(t *testing.T, fn func()) {
31 t.Helper()
32 unlockConfig := LockUserConfigEdits()
33 defer unlockConfig()
34 unlockCredentials, err := LockUserCredentialEdits()
35 if err != nil {
36 t.Fatal(err)
37 }
38 defer unlockCredentials()
39 fn()
40 }
41
42 // rotateAndStop rotates providers' key and returns before publishing, as a
43 // crash between the credential write and the config commit would.
44 func rotateAndStop(t *testing.T, path string, providers []string, value string) (*Config, string) {
45 t.Helper()
46 var cfg *Config
47 var slot string
48 withEditLocks(t, func() {
49 var err error
50 if cfg, err = LoadForEditReadOnlyStrict(path); err != nil {
51 t.Fatal(err)
52 }
53 if err := cfg.BeginModelCredentialCommitLocked(path, "cli-setup"); err != nil {
54 t.Fatal(err)
55 }
56 if slot, err = cfg.RotateModelCredentialLocked(providers, value); err != nil {
57 t.Fatal(err)
58 }
59 })
60 return cfg, slot
61 }
62
63 func storedCredentialNames(t *testing.T) []string {
64 t.Helper()
65 file, ok := readDotEnvFile(UserCredentialsPath())
66 if !ok {
67 t.Fatal("credential store unreadable")
68 }
69 names := make([]string, 0, len(file.Values))
70 for name := range file.Values {
71 names = append(names, name)
72 }
73 return names
74 }
75
76 func TestRotationRewritesANameOnlyThisProviderReads(t *testing.T) {
77 path := rotationFixture(t, teamProvider("p"))
78 cfg, slot := rotateAndStop(t, path, []string{"p"}, "sk-new")
79 withEditLocks(t, func() {
80 if err := cfg.SaveModelSettingsTo(path, cfg.ModelSettingsBaseline()); err != nil {
81 t.Fatal(err)
82 }
83 if err := cfg.MarkModelCredentialConfigCommittedLocked(path); err != nil {
84 t.Fatal(err)
85 }
86 if err := cfg.CompleteModelCredentialCommitLocked(); err != nil {
87 t.Fatal(err)
88 }
89 })
90 if slot != "TEAM_KEY" {
91 t.Fatalf("rotation used %q, want TEAM_KEY rewritten in place", slot)
92 }
93 if value, _ := envFileValue(UserCredentialsPath(), "TEAM_KEY"); value != "sk-new" || os.Getenv("TEAM_KEY") != "sk-new" {
94 t.Fatalf("TEAM_KEY stored=%q env=%q, want sk-new", value, os.Getenv("TEAM_KEY"))
95 }
96 if names := storedCredentialNames(t); len(names) != 1 || journalCount(t) != 0 {
97 t.Fatalf("store holds %v with %d journals; the previous value must be gone once published", names, journalCount(t))
98 }
99 if raw, _ := os.ReadFile(UserCredentialsPath()); strings.Contains(string(raw), "REASONIX_ROTATION_") {
100 t.Fatalf("the dropped backup left a line behind:\n%s", raw)
101 }
102 }
103
104 func TestRotationUsesAPrivateSlotFromAProjectFile(t *testing.T) {
105 rotationFixture(t)
106 project := filepath.Join(t.TempDir(), "reasonix.toml")
107 cfg := Default()
108 cfg.Providers = []ProviderEntry{teamProvider("p")}
109 if err := cfg.SaveTo(project); err != nil {
110 t.Fatal(err)
111 }
112 _, slot := rotateAndStop(t, project, []string{"p"}, "sk-new")
113 if slot == "TEAM_KEY" {
114 t.Fatal("a project file rotated a stored variable the user config may read")
115 }
116 if value, _ := envFileValue(UserCredentialsPath(), "TEAM_KEY"); value != "sk-old" {
117 t.Fatalf("TEAM_KEY = %q, want it untouched", value)
118 }
119 }
120
121 func TestRotationUsesAPrivateSlotWhenAnotherReaderShares(t *testing.T) {
122 for name, share := range map[string]func(*Config){
123 "provider": func(c *Config) { c.Providers = append(c.Providers, teamProvider("q")) },
124 "setting": func(c *Config) { c.Remote.Hosts = append(c.Remote.Hosts, RemoteHostEntry{PasswordEnv: "TEAM_KEY"}) },
125 } {
126 t.Run(name, func(t *testing.T) {
127 path := rotationFixture(t, teamProvider("p"))
128 cfg, err := LoadForEditReadOnlyStrict(path)
129 if err != nil {
130 t.Fatal(err)
131 }
132 share(cfg)
133 if err := cfg.SaveTo(path); err != nil {
134 t.Fatal(err)
135 }
136 _, slot := rotateAndStop(t, path, []string{"p"}, "sk-new")
137 if slot == "TEAM_KEY" {
138 t.Fatal("rotation rewrote a variable another reader resolves")
139 }
140 if value, _ := envFileValue(UserCredentialsPath(), "TEAM_KEY"); value != "sk-old" {
141 t.Fatalf("TEAM_KEY = %q, want the shared value untouched", value)
142 }
143 })
144 }
145 }
146
147 func TestRotationUsesAPrivateSlotForANameTheProviderOnlyNowReads(t *testing.T) {
148 path := rotationFixture(t, ProviderEntry{Name: "p", Kind: "openai", BaseURL: "https://p.invalid/v1", Model: "chat", APIKeyEnv: "P_KEY"})
149 var slot string
150 withEditLocks(t, func() {
151 cfg, err := LoadForEditReadOnlyStrict(path)
152 if err != nil {
153 t.Fatal(err)
154 }
155 if err := cfg.BeginModelCredentialCommitLocked(path, "cli-setup"); err != nil {
156 t.Fatal(err)
157 }
158 entry, _ := cfg.Provider("p")
159 entry.APIKeyEnv = "TEAM_KEY"
160 if slot, err = cfg.RotateModelCredentialLocked([]string{"p"}, "sk-new"); err != nil {
161 t.Fatal(err)
162 }
163 })
164 if slot == "TEAM_KEY" {
165 t.Fatal("rotation overwrote a stored value the provider did not read before this edit")
166 }
167 }
168
169 func TestInterruptedRotationRestoresThePreviousValue(t *testing.T) {
170 path := rotationFixture(t, teamProvider("p"))
171 rotateAndStop(t, path, []string{"p"}, "sk-new")
172 recoverLocked(t, path)
173 if value, _ := envFileValue(UserCredentialsPath(), "TEAM_KEY"); value != "sk-old" || os.Getenv("TEAM_KEY") != "sk-old" {
174 t.Fatalf("TEAM_KEY stored=%q env=%q after recovery, want sk-old back", value, os.Getenv("TEAM_KEY"))
175 }
176 if names := storedCredentialNames(t); len(names) != 1 || journalCount(t) != 0 {
177 t.Fatalf("store holds %v with %d journals after recovery", names, journalCount(t))
178 }
179 }
180
181 func TestFailedRotationCleanupRestoresThePreviousValue(t *testing.T) {
182 path := rotationFixture(t, teamProvider("p"))
183 cfg, _ := rotateAndStop(t, path, []string{"p"}, "sk-new")
184 withEditLocks(t, func() { cfg.CleanupStagedModelCredentialsLocked(path) })
185 if value, _ := envFileValue(UserCredentialsPath(), "TEAM_KEY"); value != "sk-old" {
186 t.Fatalf("TEAM_KEY = %q after a failed save, want sk-old back", value)
187 }
188 if names := storedCredentialNames(t); len(names) != 1 || journalCount(t) != 0 {
189 t.Fatalf("store holds %v with %d journals after cleanup", names, journalCount(t))
190 }
191 }
192
193 func TestRecoveryKeepsARotatedValueAnotherWriterReplaced(t *testing.T) {
194 path := rotationFixture(t, teamProvider("p"))
195 rotateAndStop(t, path, []string{"p"}, "sk-new")
196 if _, err := SetCredential("TEAM_KEY", "sk-someone-else"); err != nil {
197 t.Fatal(err)
198 }
199 recoverLocked(t, path)
200 if value, _ := envFileValue(UserCredentialsPath(), "TEAM_KEY"); value != "sk-someone-else" {
201 t.Fatalf("recovery replaced another writer's TEAM_KEY with %q", value)
202 }
203 }
204
205 func TestRotationJournalHoldsNoCredentialValue(t *testing.T) {
206 path := rotationFixture(t, teamProvider("p"))
207 cfg, _ := rotateAndStop(t, path, []string{"p"}, "sk-new")
208 raw, err := os.ReadFile(cfg.modelCredentialCommit.journalPath)
209 if err != nil {
210 t.Fatal(err)
211 }
212 if strings.Contains(string(raw), "sk-old") || strings.Contains(string(raw), "sk-new") {
213 t.Fatalf("journal carries a credential value: %s", raw)
214 }
215 }
216
216 lines GO