返回 DeepSeek-Reasonix
model_connection_regression_test.go
根目录 / internal / config / model_connection_regression_test.go
1 package config
2
3 import (
4 "os"
5 "path/filepath"
6 "runtime"
7 "strings"
8 "testing"
9
10 "reasonix/internal/fileutil"
11 )
12
13 func TestAuditRepairVerificationPreservesConcurrentSave(t *testing.T) {
14 t.Setenv("REASONIX_HOME", t.TempDir())
15 path := UserCredentialsPath()
16 if err := os.WriteFile(path, []byte("OLD=one\n"), 0600); err != nil {
17 t.Fatal(err)
18 }
19 oldHook := fileutil.CrashPoint
20 t.Cleanup(func() { fileutil.CrashPoint = oldHook })
21 // Publish before verification and assert verification performs no writes.
22 if err := os.WriteFile(path, []byte("OLD=one\nNEW=two\n"), 0600); err != nil {
23 t.Fatal(err)
24 }
25 writes := 0
26 fileutil.CrashPoint = func(_, target string) {
27 if target == path {
28 writes++
29 }
30 }
31 if err := probeCredentialTarget(path); err != nil {
32 t.Fatal(err)
33 }
34 if writes != 0 {
35 t.Fatal("verification rewrote the credential file")
36 }
37 raw, err := os.ReadFile(path)
38 if err != nil {
39 t.Fatal(err)
40 }
41 if !strings.Contains(string(raw), "NEW=two") {
42 t.Fatal("repair verification erased a concurrent credential save")
43 }
44 }
45
46 func auditCommitFixture(t *testing.T) (*Config, string) {
47 t.Helper()
48 t.Setenv("REASONIX_HOME", t.TempDir())
49 path := UserConfigPath()
50 c := Default()
51 c.Providers = []ProviderEntry{{Name: "one", Kind: "openai", BaseURL: "https://one.invalid/v1", Model: "chat", APIKeyEnv: "OLD_KEY"}}
52 if err := c.SaveTo(path); err != nil {
53 t.Fatal(err)
54 }
55 unlock := LockUserConfigEdits()
56 t.Cleanup(unlock)
57 unlockCredentials, err := LockUserCredentialEdits()
58 if err != nil {
59 t.Fatal(err)
60 }
61 t.Cleanup(unlockCredentials)
62 return c, path
63 }
64
65 func TestAuditRecoverCommittedPreferenceReceipt(t *testing.T) {
66 c, path := auditCommitFixture(t)
67 if err := c.BeginModelCredentialCommitLocked(path, "preference", "digest"); err != nil {
68 t.Fatal(err)
69 }
70 c.Language = "zh"
71 if err := c.SaveTo(path); err != nil {
72 t.Fatal(err)
73 }
74 if err := c.MarkModelCredentialConfigCommittedLocked(path); err != nil {
75 t.Fatal(err)
76 }
77 if err := RecoverModelCredentialCommitsLocked(path); err != nil {
78 t.Fatal(err)
79 }
80 if _, ok := LookupModelSettingsReceipt("preference"); !ok {
81 t.Fatal("committed preference without credential slots has no recovered receipt")
82 }
83 }
84
85 func TestAuditRecoverDoesNotInventReceiptAfterExternalEdit(t *testing.T) {
86 c, path := auditCommitFixture(t)
87 if err := c.BeginModelCredentialCommitLocked(path, "ambiguous", "digest"); err != nil {
88 t.Fatal(err)
89 }
90 slot, err := c.StageModelCredentialLocked("new-secret")
91 if err != nil {
92 t.Fatal(err)
93 }
94 c.Providers[0].APIKeyEnv = slot
95 if err := c.SaveTo(path); err != nil {
96 t.Fatal(err)
97 }
98 // Crash after publishing config, before the committed journal. An external
99 // editor then changes the model but keeps the slot reference.
100 external, err := LoadForEditReadOnlyStrict(path)
101 if err != nil {
102 t.Fatal(err)
103 }
104 external.Providers[0].Model = "external-model"
105 if err := external.SaveTo(path); err != nil {
106 t.Fatal(err)
107 }
108 if err := RecoverModelCredentialCommitsLocked(path); err != nil {
109 t.Fatal(err)
110 }
111 if _, ok := LookupModelSettingsReceipt("ambiguous"); ok {
112 t.Fatal("recovery certified an externally modified config as this request's committed result")
113 }
114 }
115
116 func TestAuditCleanupRetainsEvidenceOnFailedRemoval(t *testing.T) {
117 c, path := auditCommitFixture(t)
118 if err := c.BeginModelCredentialCommitLocked(path, "cleanup", "digest"); err != nil {
119 t.Fatal(err)
120 }
121 if _, err := c.StageModelCredentialLocked("new-secret"); err != nil {
122 t.Fatal(err)
123 }
124 journal := c.modelCredentialCommit.journalPath
125 if err := os.Remove(UserCredentialsPath()); err != nil {
126 t.Fatal(err)
127 }
128 if err := os.Mkdir(UserCredentialsPath(), 0700); err != nil {
129 t.Fatal(err)
130 }
131 c.CleanupStagedModelCredentialsLocked(path)
132 if _, err := os.Stat(journal); err != nil {
133 t.Fatalf("failed cleanup discarded recovery evidence: %v", err)
134 }
135 }
136
137 func TestAuditRepairRefusesLinkedHome(t *testing.T) {
138 outside := t.TempDir()
139 path := filepath.Join(outside, ".env")
140 if err := os.WriteFile(path, []byte("KEY=value\n"), 0400); err != nil {
141 t.Fatal(err)
142 }
143 linked := filepath.Join(t.TempDir(), "home")
144 if err := os.Symlink(outside, linked); err != nil {
145 t.Fatal(err)
146 }
147 t.Setenv("REASONIX_HOME", linked)
148 report, err := DiagnoseCredentials(CredentialDiagnosticOptions{Repair: true})
149 if err != nil {
150 t.Fatal(err)
151 }
152 if runtime.GOOS == "windows" {
153 if diagnosticStatus(report, "repair") != "failed" {
154 t.Fatalf("linked home repair status = %s, want failed", diagnosticStatus(report, "repair"))
155 }
156 return
157 }
158 info, err := os.Stat(path)
159 if err != nil {
160 t.Fatal(err)
161 }
162 if info.Mode().Perm() != 0400 {
163 t.Fatalf("repair followed linked home and changed outside permissions; status=%s", diagnosticStatus(report, "repair"))
164 }
165 }
166
167 func TestRepairPreservesCredentialContentsAndFileIdentity(t *testing.T) {
168 t.Setenv("REASONIX_HOME", t.TempDir())
169 path := UserCredentialsPath()
170 body := []byte("# unrelated content\nEXISTING_KEY=unchanged\n")
171 if err := os.WriteFile(path, body, 0400); err != nil {
172 t.Fatal(err)
173 }
174 before, err := os.Stat(path)
175 if err != nil {
176 t.Fatal(err)
177 }
178 report, err := DiagnoseCredentials(CredentialDiagnosticOptions{Repair: true})
179 if err != nil || (runtime.GOOS != "windows" && diagnosticStatus(report, "repair") != "passed") {
180 t.Fatalf("repair: %+v, %v", report, err)
181 }
182 after, err := os.Stat(path)
183 if err != nil {
184 t.Fatal(err)
185 }
186 raw, err := os.ReadFile(path)
187 if err != nil || string(raw) != string(body) || !os.SameFile(before, after) {
188 t.Fatal("repair replaced or changed credential content")
189 }
190 }
191
192 func TestReceiptQueryRecoversPublishedPreferenceBeforeMark(t *testing.T) {
193 t.Setenv("REASONIX_HOME", t.TempDir())
194 path := UserConfigPath()
195 c := Default()
196 if err := c.SaveTo(path); err != nil {
197 t.Fatal(err)
198 }
199 func() {
200 unlock := LockUserConfigEdits()
201 defer unlock()
202 unlockCredentials, err := LockUserCredentialEdits()
203 if err != nil {
204 t.Fatal(err)
205 }
206 defer unlockCredentials()
207 if err := c.BeginModelCredentialCommitLocked(path, "published-preference", "digest"); err != nil {
208 t.Fatal(err)
209 }
210 c.Language = "zh"
211 if err := c.SaveTo(path); err != nil {
212 t.Fatal(err)
213 }
214 // Stop before Mark/Complete, like a process killed just after rename.
215 }()
216 if _, ok := RecoverModelSettingsReceipt("published-preference"); !ok {
217 t.Fatal("query did not recover proven preference publication")
218 }
219 }
220
221 func TestProviderEditPathRespectsProjectOverrideOfBuiltins(t *testing.T) {
222 t.Setenv("REASONIX_HOME", t.TempDir())
223 root := t.TempDir()
224 path := filepath.Join(root, "reasonix.toml")
225 if err := os.WriteFile(path, []byte("[[providers]]\nname='deepseek'\nkind='openai'\nmodel='project-chat'\nbase_url='https://project.invalid/v1'\n"), 0600); err != nil {
226 t.Fatal(err)
227 }
228 c, err := LoadForRootReadOnly(root)
229 if err != nil {
230 t.Fatal(err)
231 }
232 actual, err := c.ProviderEditPath(root, "deepseek")
233 if err != nil || actual != path {
234 t.Fatalf("target = %q, %v", actual, err)
235 }
236 }
237
237 lines GO