返回 DeepSeek-Reasonix
model_credential_commit_test.go
根目录 / internal / config / model_credential_commit_test.go
1 package config
2
3 import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8
9 "reasonix/internal/fileutil"
10 )
11
12 func TestModelConfigRevisionUsesStableKeyedDigest(t *testing.T) {
13 isolateUserConfigHome(t)
14 first, err := modelConfigContentRevision([]byte("api_key = secret"))
15 if err != nil {
16 t.Fatal(err)
17 }
18 second, err := modelConfigContentRevision([]byte("api_key = secret"))
19 if err != nil {
20 t.Fatal(err)
21 }
22 changed, err := modelConfigContentRevision([]byte("api_key = different"))
23 if err != nil {
24 t.Fatal(err)
25 }
26 if !strings.HasPrefix(first, "hmac-sha256:") || first != second || first == changed {
27 t.Fatalf("unexpected revisions: first=%q second=%q changed=%q", first, second, changed)
28 }
29 }
30
31 func TestModelCredentialCommitRecoveryAtPersistenceBoundaries(t *testing.T) {
32 for _, crash := range []struct {
33 name string
34 journalWrite int
35 pathSuffix string
36 wantCommit bool
37 }{
38 {name: "initial journal", journalWrite: 1},
39 {name: "credential publish", pathSuffix: string(filepath.Separator) + ".env"},
40 {name: "config publish", pathSuffix: string(filepath.Separator) + "config.toml"},
41 {name: "publication candidate", journalWrite: 4},
42 {name: "committed journal", journalWrite: 5, wantCommit: true},
43 {name: "receipt publish", pathSuffix: string(filepath.Separator) + "model-settings-receipts", wantCommit: true},
44 } {
45 t.Run(crash.name, func(t *testing.T) {
46 home := t.TempDir()
47 t.Setenv("REASONIX_HOME", home)
48 path := UserConfigPath()
49 cfg := Default()
50 cfg.Providers = []ProviderEntry{{Name: "one", Kind: "openai", BaseURL: "https://one.invalid/v1", Model: "chat", APIKeyEnv: "OLD_KEY"}}
51 if err := cfg.SaveTo(path); err != nil {
52 t.Fatal(err)
53 }
54 if err := os.WriteFile(UserCredentialsPath(), []byte("OLD_KEY=old\n"), 0o600); err != nil {
55 t.Fatal(err)
56 }
57
58 journalWrites := 0
59 crashed := false
60 previous := fileutil.CrashPoint
61 fileutil.CrashPoint = func(_ string, target string) {
62 isJournal := strings.Contains(target, filepath.Join("transactions", "model-credentials"))
63 if isJournal {
64 journalWrites++
65 }
66 matches := crash.journalWrite > 0 && isJournal && journalWrites == crash.journalWrite
67 if crash.pathSuffix != "" {
68 matches = strings.HasSuffix(target, crash.pathSuffix) || strings.Contains(target, crash.pathSuffix+string(filepath.Separator))
69 }
70 if matches && !crashed {
71 crashed = true
72 panic("injected crash")
73 }
74 }
75 func() {
76 defer func() { _ = recover() }()
77 unlockConfig := LockUserConfigEdits()
78 defer unlockConfig()
79 unlockCredentials, err := LockUserCredentialEdits()
80 if err != nil {
81 t.Fatal(err)
82 }
83 defer unlockCredentials()
84 current, err := LoadForEditReadOnlyStrict(path)
85 if err != nil {
86 t.Fatal(err)
87 }
88 if err := current.BeginModelCredentialCommitLocked(path, "crash-request", "request-digest"); err != nil {
89 panic(err)
90 }
91 slot, err := current.StageModelCredentialLocked("new-secret")
92 if err != nil {
93 panic(err)
94 }
95 entry, _ := current.Provider("one")
96 updated := *entry
97 updated.APIKeyEnv = slot
98 if err := current.UpsertProvider(updated); err != nil {
99 panic(err)
100 }
101 if err := current.SaveTo(path); err != nil {
102 panic(err)
103 }
104 if err := current.MarkModelCredentialConfigCommittedLocked(path, "result-revision"); err != nil {
105 panic(err)
106 }
107 if err := current.CompleteModelCredentialCommitLocked(); err != nil {
108 panic(err)
109 }
110 }()
111 fileutil.CrashPoint = previous
112 t.Cleanup(func() { fileutil.CrashPoint = previous })
113 if !crashed {
114 t.Fatal("injected crash point did not fire")
115 }
116
117 unlockConfig := LockUserConfigEdits()
118 unlockCredentials, err := LockUserCredentialEdits()
119 if err != nil {
120 unlockConfig()
121 t.Fatal(err)
122 }
123 err = RecoverModelCredentialCommitsLocked(path)
124 unlockCredentials()
125 unlockConfig()
126 if err != nil {
127 t.Fatal(err)
128 }
129 saved, err := LoadForEditReadOnlyStrict(path)
130 if err != nil {
131 t.Fatal(err)
132 }
133 entry, _ := saved.Provider("one")
134 if crash.wantCommit {
135 if entry.APIKeyEnv == "OLD_KEY" || !CredentialStored(entry.APIKeyEnv) {
136 t.Fatalf("committed connection was not preserved: %+v", entry)
137 }
138 if receipt, ok := LookupModelSettingsReceipt("crash-request"); !ok || receipt.RequestDigest != "request-digest" {
139 t.Fatalf("commit receipt = %+v, %v", receipt, ok)
140 }
141 } else if entry.APIKeyEnv != "OLD_KEY" || !CredentialStored("OLD_KEY") {
142 t.Fatalf("uncommitted edit changed old connection: %+v", entry)
143 }
144 })
145 }
146 }
147
148 func TestModelSettingsReceiptContainsNoCredential(t *testing.T) {
149 home := t.TempDir()
150 t.Setenv("REASONIX_HOME", home)
151 j := &modelCredentialCommitJournal{
152 Schema: modelCredentialCommitSchema, RequestID: "receipt", RequestDigest: "digest",
153 ConfigPath: filepath.Join(home, "config.toml"), BeforeRevision: "before", AfterRevision: "after",
154 ResultRevision: "result", Phase: "config_committed",
155 }
156 if err := persistModelSettingsReceipt(j); err != nil {
157 t.Fatal(err)
158 }
159 raw, err := os.ReadFile(modelSettingsReceiptPath("receipt"))
160 if err != nil {
161 t.Fatal(err)
162 }
163 if strings.Contains(string(raw), "secret") || strings.Contains(string(raw), "apiKey") {
164 t.Fatalf("receipt contains credential material: %s", raw)
165 }
166 if receipt, ok := LookupModelSettingsReceipt("receipt"); !ok || receipt.ResultRevision != "result" {
167 t.Fatalf("receipt lookup = %s, %+v, %v", string(raw), receipt, ok)
168 }
169 }
170
171 func TestCleanupStagedModelCredentialWithoutJournal(t *testing.T) {
172 isolateUserConfigHome(t)
173 path := UserConfigPath()
174 cfg := Default()
175 if err := cfg.SaveTo(path); err != nil {
176 t.Fatal(err)
177 }
178
179 unlockConfig := LockUserConfigEdits()
180 unlockCredentials, err := LockUserCredentialEdits()
181 if err != nil {
182 unlockConfig()
183 t.Fatal(err)
184 }
185 current, err := LoadForEditReadOnlyStrict(path)
186 if err != nil {
187 unlockCredentials()
188 unlockConfig()
189 t.Fatal(err)
190 }
191 slot, err := current.StageModelCredentialLocked("temporary-secret")
192 if err == nil {
193 current.CleanupStagedModelCredentialsLocked(path)
194 }
195 unlockCredentials()
196 unlockConfig()
197 if err != nil {
198 t.Fatal(err)
199 }
200 if CredentialStored(slot) {
201 t.Fatalf("staged credential %q survived cleanup without a journal", slot)
202 }
203 }
204
205 func TestRecoverModelCredentialCommitRetainsEvidenceAfterConcurrentConfigChange(t *testing.T) {
206 home := t.TempDir()
207 t.Setenv("REASONIX_HOME", home)
208 path := UserConfigPath()
209 cfg := Default()
210 cfg.Providers = []ProviderEntry{{Name: "one", Kind: "openai", BaseURL: "https://one.invalid/v1", Model: "chat", APIKeyEnv: "OLD_KEY"}}
211 if err := cfg.SaveTo(path); err != nil {
212 t.Fatal(err)
213 }
214 if err := os.WriteFile(UserCredentialsPath(), []byte("OLD_KEY=old\nNEW_SLOT=new\n"), 0o600); err != nil {
215 t.Fatal(err)
216 }
217
218 updated := Default()
219 updated.Providers = []ProviderEntry{{Name: "one", Kind: "openai", BaseURL: "https://one.invalid/v1", Model: "chat", APIKeyEnv: "NEW_SLOT"}}
220 if err := updated.SaveTo(path); err != nil {
221 t.Fatal(err)
222 }
223 committedRevision := fileContentRevision(path)
224 id := "concurrent-change"
225 journal := &modelCredentialCommitJournal{
226 Schema: modelCredentialCommitSchema, TransactionID: id, RequestID: id, RequestDigest: "digest",
227 ConfigPath: path, BeforeRevision: "before", AfterRevision: committedRevision,
228 Slots: []string{"NEW_SLOT"}, Phase: "config_committed",
229 journalPath: filepath.Join(modelCredentialTransactionDir(), id+".json"),
230 }
231 if err := writeModelCredentialJournal(journal); err != nil {
232 t.Fatal(err)
233 }
234
235 // An external writer wins after the connection commit but before recovery.
236 external := Default()
237 external.Language = "en"
238 external.Providers = []ProviderEntry{{Name: "one", Kind: "openai", BaseURL: "https://one.invalid/v1", Model: "chat", APIKeyEnv: "OLD_KEY"}}
239 if err := external.SaveTo(path); err != nil {
240 t.Fatal(err)
241 }
242 unlockConfig := LockUserConfigEdits()
243 unlockCredentials, err := LockUserCredentialEdits()
244 if err != nil {
245 unlockConfig()
246 t.Fatal(err)
247 }
248 err = RecoverModelCredentialCommitsLocked(path)
249 unlockCredentials()
250 unlockConfig()
251 if err != nil {
252 t.Fatal(err)
253 }
254 if _, ok := LookupModelSettingsReceipt(id); ok {
255 t.Fatal("concurrent config change must not be converted into a success receipt")
256 }
257 if _, err := os.Stat(journal.journalPath); err != nil {
258 t.Fatalf("ambiguous transaction evidence was not retained: %v", err)
259 }
260 if !CredentialStored("NEW_SLOT") {
261 t.Fatal("ambiguous transaction slot was removed")
262 }
263 saved, err := LoadForEditReadOnlyStrict(path)
264 if err != nil {
265 t.Fatal(err)
266 }
267 provider, _ := saved.Provider("one")
268 if provider.APIKeyEnv != "OLD_KEY" || saved.Language != "en" {
269 t.Fatalf("recovery overwrote the external config: %+v", saved)
270 }
271 }
272
273 func TestCommittedModelCredentialSlotsAcceptExplicitEmptySlot(t *testing.T) {
274 home := t.TempDir()
275 t.Setenv("REASONIX_HOME", home)
276 path := UserConfigPath()
277 cfg := Default()
278 cfg.Providers = []ProviderEntry{{Name: "one", Kind: "openai", BaseURL: "https://one.invalid/v1", Model: "chat", APIKeyEnv: "EMPTY_SLOT"}}
279 if err := cfg.SaveTo(path); err != nil {
280 t.Fatal(err)
281 }
282 if err := os.WriteFile(UserCredentialsPath(), []byte("EMPTY_SLOT=\n"), 0o600); err != nil {
283 t.Fatal(err)
284 }
285 committed, err := committedModelCredentialSlots(path, []string{"EMPTY_SLOT"})
286 if err != nil {
287 t.Fatal(err)
288 }
289 if !committed {
290 t.Fatal("an explicit empty credential slot must remain a valid committed clear")
291 }
292 }
293
293 lines GO